selectorTable.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import {ReactNode, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import {PlatformIcon} from 'platformicons';
  5. import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
  6. import Link from 'sentry/components/links/link';
  7. import renderSortableHeaderCell from 'sentry/components/replays/renderSortableHeaderCell';
  8. import useQueryBasedColumnResize from 'sentry/components/replays/useQueryBasedColumnResize';
  9. import useQueryBasedSorting from 'sentry/components/replays/useQueryBasedSorting';
  10. import TextOverflow from 'sentry/components/textOverflow';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconCursorArrow} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import {ColorOrAlias} from 'sentry/utils/theme';
  16. import {useLocation} from 'sentry/utils/useLocation';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import useProjects from 'sentry/utils/useProjects';
  19. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  20. import {DeadRageSelectorItem, ReplayClickElement} from 'sentry/views/replays/types';
  21. export interface UrlState {
  22. widths: string[];
  23. }
  24. export function getAriaLabel(str: string) {
  25. const pre = str.split('aria="')[1];
  26. if (!pre) {
  27. return '';
  28. }
  29. return pre.substring(0, pre.lastIndexOf('"]'));
  30. }
  31. function trimAttribute(elementAttribute, fullAlltribute) {
  32. return elementAttribute === '' ? '' : fullAlltribute;
  33. }
  34. export function constructSelector(element: ReplayClickElement) {
  35. const fullAlt = '[alt="' + element.alt + '"]';
  36. const alt = trimAttribute(element.alt, fullAlt);
  37. const fullAriaLabel = '[aria="' + element.aria_label + '"]';
  38. const ariaLabel = trimAttribute(element.aria_label, fullAriaLabel);
  39. const trimClass = element.class.filter(e => e !== '');
  40. const classWithPeriod = trimClass.join('.');
  41. const classNoPeriod = classWithPeriod.replace('.', '');
  42. const classes = trimAttribute(classNoPeriod, '.' + classWithPeriod);
  43. const id = trimAttribute(element.id, '#' + element.id);
  44. const fullRole = '[role="' + element.role + '"]';
  45. const role = trimAttribute(element.role, fullRole);
  46. const tag = element.tag;
  47. const fullTestId = '[data-test-id="' + element.testid + '"]';
  48. const testId = trimAttribute(element.testid, fullTestId);
  49. const fullTitle = '[title="' + element.title + '"]';
  50. const title = trimAttribute(element.title, fullTitle);
  51. const fullSelector =
  52. tag + id + classes + fullRole + fullAriaLabel + fullTestId + fullAlt + fullTitle;
  53. const selector = tag + id + classes + role + ariaLabel + testId + alt + title;
  54. return {fullSelector, selector};
  55. }
  56. export function hydratedSelectorData(data, clickType?): DeadRageSelectorItem[] {
  57. return data.map(d => ({
  58. ...(clickType
  59. ? {[clickType]: d[clickType]}
  60. : {
  61. count_dead_clicks: d.count_dead_clicks,
  62. count_rage_clicks: d.count_rage_clicks,
  63. }),
  64. dom_element: {
  65. fullSelector: constructSelector(d.element).fullSelector,
  66. selector: constructSelector(d.element).selector,
  67. projectId: d.project_id,
  68. },
  69. element: d.dom_element.split(/[#.]+/)[0],
  70. aria_label: getAriaLabel(d.dom_element),
  71. project_id: d.project_id,
  72. }));
  73. }
  74. export function transformSelectorQuery(selector: string) {
  75. return selector
  76. .replaceAll('"', `\\"`)
  77. .replaceAll('aria=', 'aria-label=')
  78. .replaceAll('testid=', 'data-test-id=')
  79. .replaceAll(':', '\\:')
  80. .replaceAll('*', '\\*');
  81. }
  82. interface Props {
  83. clickCountColumns: {key: string; name: string}[];
  84. clickCountSortable: boolean;
  85. data: DeadRageSelectorItem[];
  86. isError: boolean;
  87. isLoading: boolean;
  88. location: Location<any>;
  89. title?: ReactNode;
  90. }
  91. const BASE_COLUMNS: GridColumnOrder<string>[] = [
  92. {key: 'project_id', name: 'project'},
  93. {key: 'element', name: 'element'},
  94. {key: 'dom_element', name: 'selector'},
  95. {key: 'aria_label', name: 'aria label'},
  96. ];
  97. export function ProjectInfo({id, isWidget}: {id: number; isWidget: boolean}) {
  98. const {projects} = useProjects();
  99. const project = projects.find(p => p.id === id.toString());
  100. const platform = project?.platform;
  101. const slug = project?.slug;
  102. return isWidget ? (
  103. <WidgetProjectContainer>
  104. <Tooltip title={slug}>
  105. <PlatformIcon size={16} platform={platform ?? 'default'} />
  106. </Tooltip>
  107. </WidgetProjectContainer>
  108. ) : (
  109. <IndexProjectContainer>
  110. <PlatformIcon size={16} platform={platform ?? 'default'} />
  111. <TextOverflow>{slug}</TextOverflow>
  112. </IndexProjectContainer>
  113. );
  114. }
  115. export default function SelectorTable({
  116. clickCountColumns,
  117. data,
  118. isError,
  119. isLoading,
  120. location,
  121. title,
  122. clickCountSortable,
  123. }: Props) {
  124. const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
  125. defaultSort: {field: clickCountColumns[0].key, kind: 'desc'},
  126. location,
  127. });
  128. const {columns, handleResizeColumn} = useQueryBasedColumnResize({
  129. columns: BASE_COLUMNS.concat(clickCountColumns),
  130. location,
  131. });
  132. const renderHeadCell = useMemo(
  133. () =>
  134. renderSortableHeaderCell({
  135. currentSort,
  136. makeSortLinkGenerator,
  137. onClick: () => {},
  138. rightAlignedColumns: [],
  139. sortableColumns: clickCountSortable ? clickCountColumns : [],
  140. }),
  141. [currentSort, makeSortLinkGenerator, clickCountColumns, clickCountSortable]
  142. );
  143. const queryPrefix = currentSort.field.includes('count_dead_clicks') ? 'dead' : 'rage';
  144. const renderBodyCell = useCallback(
  145. (column, dataRow) => {
  146. const value = dataRow[column.key];
  147. switch (column.key) {
  148. case 'dom_element':
  149. return (
  150. <SelectorLink
  151. value={value.selector}
  152. selectorQuery={`${queryPrefix}.selector:"${transformSelectorQuery(
  153. value.fullSelector
  154. )}"`}
  155. projectId={value.projectId.toString()}
  156. />
  157. );
  158. case 'element':
  159. case 'aria_label':
  160. return <TextOverflow>{value}</TextOverflow>;
  161. case 'project_id':
  162. return <ProjectInfo id={value} isWidget={false} />;
  163. default:
  164. return renderClickCount<DeadRageSelectorItem>(column, dataRow);
  165. }
  166. },
  167. [queryPrefix]
  168. );
  169. const selectorEmptyMessage = (
  170. <MessageContainer>
  171. <Title>{t('No dead or rage clicks found')}</Title>
  172. <Subtitle>
  173. {t(
  174. "Once your users start clicking around, you'll see the top selectors that were dead or rage clicked here."
  175. )}
  176. </Subtitle>
  177. </MessageContainer>
  178. );
  179. return (
  180. <GridEditable
  181. error={isError}
  182. isLoading={isLoading}
  183. data={data ?? []}
  184. columnOrder={columns}
  185. emptyMessage={selectorEmptyMessage}
  186. columnSortBy={[]}
  187. stickyHeader
  188. grid={{
  189. onResizeColumn: handleResizeColumn,
  190. renderHeadCell,
  191. renderBodyCell,
  192. }}
  193. location={location as Location<any>}
  194. title={title}
  195. />
  196. );
  197. }
  198. export function SelectorLink({
  199. value,
  200. selectorQuery,
  201. projectId,
  202. }: {
  203. projectId: string;
  204. selectorQuery: string;
  205. value: string;
  206. }) {
  207. const organization = useOrganization();
  208. const location = useLocation();
  209. return (
  210. <StyledTextOverflow>
  211. <Link
  212. to={{
  213. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
  214. query: {
  215. ...location.query,
  216. query: selectorQuery,
  217. cursor: undefined,
  218. project: projectId,
  219. },
  220. }}
  221. >
  222. <StyledTooltip
  223. position="top-start"
  224. title={t('Search for replays with clicks on this selector')}
  225. >
  226. {value}
  227. </StyledTooltip>
  228. </Link>
  229. </StyledTextOverflow>
  230. );
  231. }
  232. function renderClickCount<T>(column: GridColumnOrder<string>, dataRow: T) {
  233. const color = column.key === 'count_dead_clicks' ? 'yellow300' : 'red300';
  234. return (
  235. <ClickColor color={color}>
  236. <IconCursorArrow size="xs" />
  237. {dataRow[column.key]}
  238. </ClickColor>
  239. );
  240. }
  241. const ClickColor = styled(TextOverflow)<{color: ColorOrAlias}>`
  242. color: ${p => p.theme[p.color]};
  243. display: grid;
  244. grid-template-columns: auto auto;
  245. gap: ${space(0.75)};
  246. align-items: center;
  247. justify-content: start;
  248. `;
  249. const StyledTextOverflow = styled(TextOverflow)`
  250. color: ${p => p.theme.blue300};
  251. `;
  252. const StyledTooltip = styled(Tooltip)`
  253. display: inherit;
  254. `;
  255. const Subtitle = styled('div')`
  256. font-size: ${p => p.theme.fontSizeMedium};
  257. `;
  258. const Title = styled('div')`
  259. font-size: 24px;
  260. `;
  261. const MessageContainer = styled('div')`
  262. display: grid;
  263. grid-auto-flow: row;
  264. gap: ${space(1)};
  265. justify-items: center;
  266. `;
  267. const WidgetProjectContainer = styled('div')`
  268. display: flex;
  269. flex-direction: row;
  270. align-items: center;
  271. gap: ${space(0.75)};
  272. `;
  273. const IndexProjectContainer = styled(WidgetProjectContainer)`
  274. padding-right: ${space(1)};
  275. `;