selectorTable.tsx 7.8 KB

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