selectorTable.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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} 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. export function hydratedSelectorData(data, clickType?): DeadRageSelectorItem[] {
  32. return data.map(d => ({
  33. ...(clickType
  34. ? {[clickType]: d[clickType]}
  35. : {
  36. count_dead_clicks: d.count_dead_clicks,
  37. count_rage_clicks: d.count_rage_clicks,
  38. }),
  39. dom_element: d.dom_element,
  40. element: d.dom_element.split(/[#.]+/)[0],
  41. aria_label: getAriaLabel(d.dom_element),
  42. project_id: d.project_id,
  43. }));
  44. }
  45. export function transformSelectorQuery(selector: string) {
  46. return selector
  47. .replaceAll('"', `\\"`)
  48. .replaceAll('aria=', 'aria-label=')
  49. .replaceAll('testid=', 'data-test-id=')
  50. .replaceAll(':', '\\:')
  51. .replaceAll('*', '\\*');
  52. }
  53. interface Props {
  54. clickCountColumns: {key: string; name: string}[];
  55. clickCountSortable: boolean;
  56. data: DeadRageSelectorItem[];
  57. isError: boolean;
  58. isLoading: boolean;
  59. location: Location<any>;
  60. title?: ReactNode;
  61. }
  62. const BASE_COLUMNS: GridColumnOrder<string>[] = [
  63. {key: 'project_id', name: 'project'},
  64. {key: 'element', name: 'element'},
  65. {key: 'dom_element', name: 'selector'},
  66. {key: 'aria_label', name: 'aria label'},
  67. ];
  68. export function ProjectInfo({id, isWidget}: {id: number; isWidget: boolean}) {
  69. const {projects} = useProjects();
  70. const project = projects.find(p => p.id === id.toString());
  71. const platform = project?.platform;
  72. const slug = project?.slug;
  73. return isWidget ? (
  74. <WidgetProjectContainer>
  75. <Tooltip title={slug}>
  76. <PlatformIcon size={16} platform={platform ?? 'default'} />
  77. </Tooltip>
  78. </WidgetProjectContainer>
  79. ) : (
  80. <IndexProjectContainer>
  81. <PlatformIcon size={16} platform={platform ?? 'default'} />
  82. <TextOverflow>{slug}</TextOverflow>
  83. </IndexProjectContainer>
  84. );
  85. }
  86. export default function SelectorTable({
  87. clickCountColumns,
  88. data,
  89. isError,
  90. isLoading,
  91. location,
  92. title,
  93. clickCountSortable,
  94. }: Props) {
  95. const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
  96. defaultSort: {field: clickCountColumns[0].key, kind: 'desc'},
  97. location,
  98. });
  99. const {columns, handleResizeColumn} = useQueryBasedColumnResize({
  100. columns: BASE_COLUMNS.concat(clickCountColumns),
  101. location,
  102. });
  103. const renderHeadCell = useMemo(
  104. () =>
  105. renderSortableHeaderCell({
  106. currentSort,
  107. makeSortLinkGenerator,
  108. onClick: () => {},
  109. rightAlignedColumns: [],
  110. sortableColumns: clickCountSortable ? clickCountColumns : [],
  111. }),
  112. [currentSort, makeSortLinkGenerator, clickCountColumns, clickCountSortable]
  113. );
  114. const queryPrefix = currentSort.field.includes('count_dead_clicks') ? 'dead' : 'rage';
  115. const renderBodyCell = useCallback(
  116. (column, dataRow) => {
  117. const value = dataRow[column.key];
  118. switch (column.key) {
  119. case 'dom_element':
  120. return (
  121. <SelectorLink
  122. value={value}
  123. selectorQuery={`${queryPrefix}.selector:"${transformSelectorQuery(value)}"`}
  124. />
  125. );
  126. case 'element':
  127. case 'aria_label':
  128. return <TextOverflow>{value}</TextOverflow>;
  129. case 'project_id':
  130. return <ProjectInfo id={value} isWidget={false} />;
  131. default:
  132. return renderClickCount<DeadRageSelectorItem>(column, dataRow);
  133. }
  134. },
  135. [queryPrefix]
  136. );
  137. return (
  138. <GridEditable
  139. error={isError}
  140. isLoading={isLoading}
  141. data={data ?? []}
  142. columnOrder={columns}
  143. columnSortBy={[]}
  144. stickyHeader
  145. grid={{
  146. onResizeColumn: handleResizeColumn,
  147. renderHeadCell,
  148. renderBodyCell,
  149. }}
  150. location={location as Location<any>}
  151. title={title}
  152. />
  153. );
  154. }
  155. export function SelectorLink({
  156. value,
  157. selectorQuery,
  158. }: {
  159. selectorQuery: string;
  160. value: string;
  161. }) {
  162. const organization = useOrganization();
  163. const location = useLocation();
  164. return (
  165. <StyledTextOverflow>
  166. <Link
  167. to={{
  168. pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
  169. query: {
  170. ...location.query,
  171. query: selectorQuery,
  172. cursor: undefined,
  173. },
  174. }}
  175. >
  176. <StyledTooltip
  177. position="top-start"
  178. title={t('Search for replays with clicks on this selector')}
  179. >
  180. {value}
  181. </StyledTooltip>
  182. </Link>
  183. </StyledTextOverflow>
  184. );
  185. }
  186. function renderClickCount<T>(column: GridColumnOrder<string>, dataRow: T) {
  187. const color = column.key === 'count_dead_clicks' ? 'yellow300' : 'red300';
  188. return (
  189. <ClickColor color={color}>
  190. <IconCursorArrow size="xs" />
  191. {dataRow[column.key]}
  192. </ClickColor>
  193. );
  194. }
  195. const ClickColor = styled(TextOverflow)<{color: ColorOrAlias}>`
  196. color: ${p => p.theme[p.color]};
  197. display: grid;
  198. grid-template-columns: auto auto;
  199. gap: ${space(0.75)};
  200. align-items: center;
  201. justify-content: start;
  202. `;
  203. const StyledTextOverflow = styled(TextOverflow)`
  204. color: ${p => p.theme.blue300};
  205. `;
  206. const StyledTooltip = styled(Tooltip)`
  207. display: inherit;
  208. `;
  209. const WidgetProjectContainer = styled('div')`
  210. display: flex;
  211. flex-direction: row;
  212. align-items: center;
  213. gap: ${space(0.75)};
  214. `;
  215. const IndexProjectContainer = styled(WidgetProjectContainer)`
  216. padding-right: ${space(1)};
  217. `;