deadRageSelectorCards.tsx 9.2 KB

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