deadRageSelectorCards.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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 LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import QuestionTooltip from 'sentry/components/questionTooltip';
  7. import TextOverflow from 'sentry/components/textOverflow';
  8. import {IconCursorArrow} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
  12. import {ColorOrAlias} from 'sentry/utils/theme';
  13. import {useLocation} from 'sentry/utils/useLocation';
  14. import useOrganization from 'sentry/utils/useOrganization';
  15. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  16. import Accordion from 'sentry/views/performance/landing/widgets/components/accordion';
  17. import {RightAlignedCell} from 'sentry/views/performance/landing/widgets/components/selectableList';
  18. import {
  19. ContentContainer,
  20. HeaderContainer,
  21. HeaderTitleLegend,
  22. StatusContainer,
  23. Subtitle,
  24. WidgetContainer,
  25. } from 'sentry/views/profiling/landing/styles';
  26. import ExampleReplaysList from 'sentry/views/replays/deadRageClick/exampleReplaysList';
  27. function DeadRageSelectorCards() {
  28. return (
  29. <SplitCardContainer>
  30. <AccordionWidget
  31. clickType="count_dead_clicks"
  32. header={
  33. <div>
  34. <StyledWidgetHeader>
  35. {t('Most Dead Clicks')}
  36. <QuestionTooltip
  37. size="xs"
  38. position="top"
  39. title={t('The top selectors your users have dead clicked on.')}
  40. isHoverable
  41. />
  42. </StyledWidgetHeader>
  43. <Subtitle>{t('Suggested replays to watch')}</Subtitle>
  44. </div>
  45. }
  46. deadOrRage="dead"
  47. />
  48. <AccordionWidget
  49. clickType="count_rage_clicks"
  50. header={
  51. <div>
  52. <StyledWidgetHeader>
  53. {t('Most Rage Clicks')}
  54. <QuestionTooltip
  55. size="xs"
  56. position="top"
  57. title={t('The top selectors your users have rage clicked on.')}
  58. isHoverable
  59. />
  60. </StyledWidgetHeader>
  61. <Subtitle>{t('Suggested replays to watch')}</Subtitle>
  62. </div>
  63. }
  64. deadOrRage="rage"
  65. />
  66. </SplitCardContainer>
  67. );
  68. }
  69. function AccordionWidget({
  70. clickType,
  71. deadOrRage,
  72. header,
  73. }: {
  74. clickType: 'count_dead_clicks' | 'count_rage_clicks';
  75. deadOrRage: 'dead' | 'rage';
  76. header: ReactNode;
  77. }) {
  78. const [selectedListIndex, setSelectListIndex] = useState(0);
  79. const {isLoading, isError, data} = useDeadRageSelectors({
  80. per_page: 3,
  81. sort: `-${clickType}`,
  82. cursor: undefined,
  83. prefix: 'selector_',
  84. isWidgetData: true,
  85. });
  86. const location = useLocation();
  87. const filteredData = data.filter(d => (d[clickType] ?? 0) > 0);
  88. const clickColor = deadOrRage === 'dead' ? ('yellow300' as ColorOrAlias) : 'red300';
  89. return (
  90. <StyledWidgetContainer>
  91. <StyledHeaderContainer>
  92. <ClickColor color={clickColor}>
  93. <IconCursorArrow />
  94. </ClickColor>
  95. {header}
  96. </StyledHeaderContainer>
  97. {isLoading && (
  98. <StatusContainer>
  99. <LoadingIndicator />
  100. </StatusContainer>
  101. )}
  102. {isError || (!isLoading && filteredData.length === 0) ? (
  103. <CenteredContentContainer>
  104. <EmptyStateWarning>
  105. <p>{t('No results found')}</p>
  106. </EmptyStateWarning>
  107. </CenteredContentContainer>
  108. ) : (
  109. <LeftAlignedContentContainer>
  110. <Accordion
  111. expandedIndex={selectedListIndex}
  112. setExpandedIndex={setSelectListIndex}
  113. items={filteredData.map(d => {
  114. return {
  115. header: () => (
  116. <AccordionItemHeader
  117. count={d[clickType] ?? 0}
  118. selector={d.dom_element}
  119. clickColor={clickColor}
  120. />
  121. ),
  122. content: () => (
  123. <ExampleReplaysList
  124. location={location}
  125. clickType={clickType}
  126. query={`${deadOrRage}.selector:"${transformSelectorQuery(
  127. d.dom_element
  128. )}"`}
  129. />
  130. ),
  131. };
  132. })}
  133. />
  134. </LeftAlignedContentContainer>
  135. )}
  136. <SearchButton
  137. label={t('See all selectors')}
  138. path="selectors"
  139. sort={`-${clickType}`}
  140. />
  141. </StyledWidgetContainer>
  142. );
  143. }
  144. function transformSelectorQuery(selector: string) {
  145. return selector
  146. .replaceAll('"', `\\"`)
  147. .replaceAll('aria=', 'aria-label=')
  148. .replaceAll('testid=', 'data-test-id=');
  149. }
  150. function AccordionItemHeader({
  151. count,
  152. clickColor,
  153. selector,
  154. }: {
  155. clickColor: ColorOrAlias;
  156. count: number;
  157. selector: string;
  158. }) {
  159. const clickCount = (
  160. <ClickColor color={clickColor}>
  161. <IconCursorArrow size="xs" />
  162. {count}
  163. </ClickColor>
  164. );
  165. return (
  166. <StyledAccordionHeader>
  167. <TextOverflow>
  168. <code>{selector}</code>
  169. </TextOverflow>
  170. <RightAlignedCell>{clickCount}</RightAlignedCell>
  171. </StyledAccordionHeader>
  172. );
  173. }
  174. function SearchButton({
  175. label,
  176. sort,
  177. path,
  178. ...props
  179. }: {
  180. label: ReactNode;
  181. path: string;
  182. sort: string;
  183. } & Omit<ComponentProps<typeof LinkButton>, 'size' | 'to'>) {
  184. const location = useLocation();
  185. const organization = useOrganization();
  186. return (
  187. <StyledButton
  188. {...props}
  189. size="xs"
  190. to={{
  191. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${path}/`),
  192. query: {
  193. ...location.query,
  194. sort,
  195. query: undefined,
  196. cursor: undefined,
  197. },
  198. }}
  199. >
  200. {label}
  201. </StyledButton>
  202. );
  203. }
  204. const SplitCardContainer = styled('div')`
  205. display: grid;
  206. grid-template-columns: 1fr 1fr;
  207. grid-template-rows: max-content;
  208. grid-auto-flow: column;
  209. gap: 0 ${space(2)};
  210. align-items: stretch;
  211. `;
  212. const ClickColor = styled(TextOverflow)<{color: ColorOrAlias}>`
  213. color: ${p => p.theme[p.color]};
  214. display: grid;
  215. grid-template-columns: 1fr 1fr;
  216. gap: ${space(0.5)};
  217. align-items: center;
  218. `;
  219. const StyledHeaderContainer = styled(HeaderContainer)`
  220. grid-auto-flow: row;
  221. align-items: center;
  222. grid-template-rows: auto;
  223. grid-template-columns: 30px auto;
  224. `;
  225. const LeftAlignedContentContainer = styled(ContentContainer)`
  226. justify-content: flex-start;
  227. `;
  228. const CenteredContentContainer = styled(ContentContainer)`
  229. justify-content: center;
  230. `;
  231. const StyledButton = styled(LinkButton)`
  232. width: 100%;
  233. border-radius: ${p => p.theme.borderRadiusBottom};
  234. padding: ${space(3)};
  235. border-bottom: none;
  236. border-left: none;
  237. border-right: none;
  238. font-size: ${p => p.theme.fontSizeMedium};
  239. `;
  240. const StyledAccordionHeader = styled('div')`
  241. display: grid;
  242. grid-template-columns: 1fr max-content;
  243. flex: 1;
  244. `;
  245. const StyledWidgetHeader = styled(HeaderTitleLegend)`
  246. display: grid;
  247. gap: ${space(1)};
  248. justify-content: start;
  249. align-items: center;
  250. `;
  251. const StyledWidgetContainer = styled(WidgetContainer)`
  252. margin-bottom: 0;
  253. `;
  254. export default DeadRageSelectorCards;