deadRageSelectorCards.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import {ComponentProps, ReactNode, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {LinkButton} from 'sentry/components/button';
  4. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  5. import FeatureBadge from 'sentry/components/featureBadge';
  6. import Placeholder from 'sentry/components/placeholder';
  7. import QuestionTooltip from 'sentry/components/questionTooltip';
  8. import TextOverflow from 'sentry/components/textOverflow';
  9. import {IconCursorArrow} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
  13. import {ColorOrAlias} from 'sentry/utils/theme';
  14. import {useLocation} from 'sentry/utils/useLocation';
  15. import useOrganization from 'sentry/utils/useOrganization';
  16. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  17. import Accordion from 'sentry/views/performance/landing/widgets/components/accordion';
  18. import {
  19. ContentContainer,
  20. HeaderContainer,
  21. HeaderTitleLegend,
  22. Subtitle,
  23. WidgetContainer,
  24. } from 'sentry/views/profiling/landing/styles';
  25. import ExampleReplaysList from 'sentry/views/replays/deadRageClick/exampleReplaysList';
  26. import {
  27. ProjectInfo,
  28. SelectorLink,
  29. transformSelectorQuery,
  30. } from 'sentry/views/replays/deadRageClick/selectorTable';
  31. function DeadRageSelectorCards() {
  32. return (
  33. <SplitCardContainer>
  34. <AccordionWidget
  35. clickType="count_dead_clicks"
  36. header={
  37. <div>
  38. <StyledWidgetHeader>
  39. <TitleTooltipContainer>
  40. {t('Most Dead Clicks')}
  41. <QuestionTooltip
  42. size="xs"
  43. position="top"
  44. title={t('The top selectors your users have dead clicked on.')}
  45. isHoverable
  46. />
  47. </TitleTooltipContainer>
  48. <FeatureBadge type="beta" />
  49. </StyledWidgetHeader>
  50. <Subtitle>{t('Suggested replays to watch')}</Subtitle>
  51. </div>
  52. }
  53. deadOrRage="dead"
  54. />
  55. <AccordionWidget
  56. clickType="count_rage_clicks"
  57. header={
  58. <div>
  59. <StyledWidgetHeader>
  60. <TitleTooltipContainer>
  61. {t('Most Rage Clicks')}
  62. <QuestionTooltip
  63. size="xs"
  64. position="top"
  65. title={t('The top selectors your users have rage clicked on.')}
  66. isHoverable
  67. />
  68. </TitleTooltipContainer>
  69. <FeatureBadge type="beta" />
  70. </StyledWidgetHeader>
  71. <Subtitle>{t('Suggested replays to watch')}</Subtitle>
  72. </div>
  73. }
  74. deadOrRage="rage"
  75. />
  76. </SplitCardContainer>
  77. );
  78. }
  79. function AccordionWidget({
  80. clickType,
  81. deadOrRage,
  82. header,
  83. }: {
  84. clickType: 'count_dead_clicks' | 'count_rage_clicks';
  85. deadOrRage: 'dead' | 'rage';
  86. header: ReactNode;
  87. }) {
  88. const [selectedListIndex, setSelectListIndex] = useState(-1);
  89. const {isLoading, isError, data} = useDeadRageSelectors({
  90. per_page: 3,
  91. sort: `-${clickType}`,
  92. cursor: undefined,
  93. prefix: 'selector_',
  94. isWidgetData: true,
  95. });
  96. const location = useLocation();
  97. const filteredData = data.filter(d => (d[clickType] ?? 0) > 0);
  98. const clickColor = deadOrRage === 'dead' ? 'yellow300' : 'red300';
  99. return (
  100. <StyledWidgetContainer>
  101. <StyledHeaderContainer>
  102. <ClickColor color={clickColor}>
  103. <IconCursorArrow />
  104. </ClickColor>
  105. {header}
  106. </StyledHeaderContainer>
  107. {isLoading ? (
  108. <LoadingContainer>
  109. <StyledPlaceholder />
  110. <StyledPlaceholder />
  111. <StyledPlaceholder />
  112. </LoadingContainer>
  113. ) : isError || (!isLoading && filteredData.length === 0) ? (
  114. <CenteredContentContainer>
  115. <EmptyStateWarning>
  116. <div>{t('No results found')}</div>
  117. <EmptySubtitle>
  118. {tct(
  119. "Once your users start clicking around, you'll see the top selectors that were [type] clicked here.",
  120. {type: deadOrRage}
  121. )}
  122. </EmptySubtitle>
  123. </EmptyStateWarning>
  124. </CenteredContentContainer>
  125. ) : (
  126. <LeftAlignedContentContainer>
  127. <Accordion
  128. buttonOnLeft
  129. expandedIndex={selectedListIndex}
  130. setExpandedIndex={setSelectListIndex}
  131. items={filteredData.map(d => {
  132. const selectorQuery = `${deadOrRage}.selector:"${transformSelectorQuery(
  133. d.dom_element.fullSelector
  134. )}"`;
  135. return {
  136. header: () => (
  137. <AccordionItemHeader
  138. count={d[clickType] ?? 0}
  139. selector={d.dom_element.selector}
  140. clickColor={clickColor}
  141. selectorQuery={selectorQuery}
  142. id={d.project_id}
  143. />
  144. ),
  145. content: () => (
  146. <ExampleReplaysList
  147. location={location}
  148. clickType={clickType}
  149. selectorQuery={selectorQuery}
  150. projectId={d.project_id}
  151. />
  152. ),
  153. };
  154. })}
  155. />
  156. </LeftAlignedContentContainer>
  157. )}
  158. <SearchButton
  159. label={t('See all selectors')}
  160. path="selectors"
  161. sort={`-${clickType}`}
  162. />
  163. </StyledWidgetContainer>
  164. );
  165. }
  166. function AccordionItemHeader({
  167. count,
  168. clickColor,
  169. selector,
  170. selectorQuery,
  171. id,
  172. }: {
  173. clickColor: ColorOrAlias;
  174. count: number;
  175. id: number;
  176. selector: string;
  177. selectorQuery: string;
  178. }) {
  179. const clickCount = (
  180. <ClickColor color={clickColor}>
  181. <IconCursorArrow size="xs" />
  182. {count}
  183. </ClickColor>
  184. );
  185. return (
  186. <StyledAccordionHeader>
  187. <SelectorLink
  188. value={selector}
  189. selectorQuery={selectorQuery}
  190. projectId={id.toString()}
  191. />
  192. <RightAlignedCell>
  193. {clickCount}
  194. <ProjectInfo id={id} isWidget />
  195. </RightAlignedCell>
  196. </StyledAccordionHeader>
  197. );
  198. }
  199. function SearchButton({
  200. label,
  201. sort,
  202. path,
  203. ...props
  204. }: {
  205. label: ReactNode;
  206. path: string;
  207. sort: string;
  208. } & Omit<ComponentProps<typeof LinkButton>, 'size' | 'to'>) {
  209. const location = useLocation();
  210. const organization = useOrganization();
  211. return (
  212. <StyledButton
  213. {...props}
  214. size="xs"
  215. to={{
  216. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${path}/`),
  217. query: {
  218. ...location.query,
  219. sort,
  220. query: undefined,
  221. cursor: undefined,
  222. },
  223. }}
  224. >
  225. {label}
  226. </StyledButton>
  227. );
  228. }
  229. const SplitCardContainer = styled('div')`
  230. display: grid;
  231. grid-template-columns: 1fr 1fr;
  232. grid-template-rows: max-content;
  233. grid-auto-flow: column;
  234. gap: 0 ${space(2)};
  235. align-items: stretch;
  236. `;
  237. const ClickColor = styled(TextOverflow)<{color: ColorOrAlias}>`
  238. color: ${p => p.theme[p.color]};
  239. display: grid;
  240. grid-template-columns: auto auto;
  241. gap: ${space(0.75)};
  242. align-items: center;
  243. `;
  244. const StyledHeaderContainer = styled(HeaderContainer)`
  245. grid-auto-flow: row;
  246. align-items: center;
  247. grid-template-rows: auto;
  248. grid-template-columns: 30px auto;
  249. `;
  250. const LeftAlignedContentContainer = styled(ContentContainer)`
  251. justify-content: flex-start;
  252. `;
  253. const CenteredContentContainer = styled(ContentContainer)`
  254. justify-content: center;
  255. `;
  256. const StyledButton = styled(LinkButton)`
  257. width: 100%;
  258. border-radius: ${p => p.theme.borderRadiusBottom};
  259. padding: ${space(3)};
  260. border-bottom: none;
  261. border-left: none;
  262. border-right: none;
  263. font-size: ${p => p.theme.fontSizeMedium};
  264. `;
  265. const StyledAccordionHeader = styled('div')`
  266. display: grid;
  267. grid-template-columns: 1fr max-content;
  268. flex: 1;
  269. `;
  270. const TitleTooltipContainer = styled('div')`
  271. display: flex;
  272. gap: ${space(1)};
  273. align-items: center;
  274. `;
  275. const StyledWidgetHeader = styled(HeaderTitleLegend)`
  276. display: grid;
  277. justify-content: space-between;
  278. align-items: center;
  279. `;
  280. const StyledWidgetContainer = styled(WidgetContainer)`
  281. margin-bottom: 0;
  282. padding-top: ${space(1.5)};
  283. `;
  284. export const RightAlignedCell = styled('div')`
  285. text-align: right;
  286. display: flex;
  287. align-items: center;
  288. justify-content: center;
  289. gap: ${space(1)};
  290. `;
  291. const EmptySubtitle = styled('div')`
  292. font-size: ${p => p.theme.fontSizeMedium};
  293. line-height: 1.8em;
  294. padding-left: ${space(1)};
  295. padding-right: ${space(1)};
  296. `;
  297. const LoadingContainer = styled(ContentContainer)`
  298. gap: ${space(0.25)};
  299. padding: ${space(1)} ${space(0.5)} 3px ${space(0.5)};
  300. `;
  301. const StyledPlaceholder = styled(Placeholder)`
  302. height: 34px;
  303. `;
  304. export default DeadRageSelectorCards;