selectorTable.tsx 7.2 KB

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