selectorTable.tsx 7.3 KB

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