selectorTable.tsx 9.3 KB

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