123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import {ReactNode, useCallback, useMemo} from 'react';
- import styled from '@emotion/styled';
- import type {Location} from 'history';
- import {PlatformIcon} from 'platformicons';
- import {CodeSnippet} from 'sentry/components/codeSnippet';
- import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
- import Link from 'sentry/components/links/link';
- import renderSortableHeaderCell from 'sentry/components/replays/renderSortableHeaderCell';
- import useQueryBasedColumnResize from 'sentry/components/replays/useQueryBasedColumnResize';
- import useQueryBasedSorting from 'sentry/components/replays/useQueryBasedSorting';
- import TextOverflow from 'sentry/components/textOverflow';
- import {Tooltip} from 'sentry/components/tooltip';
- import {IconCursorArrow} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {useLocation} from 'sentry/utils/useLocation';
- import useOrganization from 'sentry/utils/useOrganization';
- import useProjects from 'sentry/utils/useProjects';
- import {normalizeUrl} from 'sentry/utils/withDomainRequired';
- import {constructSelector, getAriaLabel} from 'sentry/views/replays/detail/utils';
- import {DeadRageSelectorItem} from 'sentry/views/replays/types';
- import {WiderHovercard} from 'sentry/views/starfish/components/tableCells/spanDescriptionCell';
- export interface UrlState {
- widths: string[];
- }
- export function hydratedSelectorData(data, clickType?): DeadRageSelectorItem[] {
- return data.map(d => ({
- ...(clickType
- ? {[clickType]: d[clickType]}
- : {
- count_dead_clicks: d.count_dead_clicks,
- count_rage_clicks: d.count_rage_clicks,
- }),
- dom_element: {
- fullSelector: constructSelector(d.element).fullSelector,
- selector: constructSelector(d.element).selector,
- projectId: d.project_id,
- },
- element: d.dom_element.split(/[#.[]+/)[0],
- aria_label: getAriaLabel(d.dom_element),
- project_id: d.project_id,
- }));
- }
- export function transformSelectorQuery(selector: string) {
- return selector
- .replaceAll('"', `\\"`)
- .replaceAll('aria=', 'aria-label=')
- .replaceAll('testid=', 'data-test-id=')
- .replaceAll(':', '\\:')
- .replaceAll('*', '\\*');
- }
- interface Props {
- clickCountColumns: {key: string; name: string}[];
- clickCountSortable: boolean;
- data: DeadRageSelectorItem[];
- isError: boolean;
- isLoading: boolean;
- location: Location<any>;
- title?: ReactNode;
- }
- const BASE_COLUMNS: GridColumnOrder<string>[] = [
- {key: 'project_id', name: 'project'},
- {key: 'element', name: 'element'},
- {key: 'dom_element', name: 'selector'},
- {key: 'aria_label', name: 'aria label'},
- ];
- export function ProjectInfo({id, isWidget}: {id: number; isWidget: boolean}) {
- const {projects} = useProjects();
- const project = projects.find(p => p.id === id.toString());
- const platform = project?.platform;
- const slug = project?.slug;
- return isWidget ? (
- <WidgetProjectContainer>
- <Tooltip title={slug}>
- <PlatformIcon size={16} platform={platform ?? 'default'} />
- </Tooltip>
- </WidgetProjectContainer>
- ) : (
- <IndexProjectContainer>
- <PlatformIcon size={16} platform={platform ?? 'default'} />
- <TextOverflow>{slug}</TextOverflow>
- </IndexProjectContainer>
- );
- }
- export default function SelectorTable({
- clickCountColumns,
- data,
- isError,
- isLoading,
- location,
- title,
- clickCountSortable,
- }: Props) {
- const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
- defaultSort: {field: clickCountColumns[0].key, kind: 'desc'},
- location,
- });
- const {columns, handleResizeColumn} = useQueryBasedColumnResize({
- columns: BASE_COLUMNS.concat(clickCountColumns),
- location,
- });
- const renderHeadCell = useMemo(
- () =>
- renderSortableHeaderCell({
- currentSort,
- makeSortLinkGenerator,
- onClick: () => {},
- rightAlignedColumns: [],
- sortableColumns: clickCountSortable ? clickCountColumns : [],
- }),
- [currentSort, makeSortLinkGenerator, clickCountColumns, clickCountSortable]
- );
- const queryPrefix = currentSort.field.includes('count_dead_clicks') ? 'dead' : 'rage';
- const renderBodyCell = useCallback(
- (column, dataRow) => {
- const value = dataRow[column.key];
- switch (column.key) {
- case 'dom_element':
- return (
- <SelectorLink
- value={value.selector}
- selectorQuery={`${queryPrefix}.selector:"${transformSelectorQuery(
- value.fullSelector
- )}"`}
- projectId={value.projectId.toString()}
- />
- );
- case 'element':
- case 'aria_label':
- return <TextOverflow>{value}</TextOverflow>;
- case 'project_id':
- return <ProjectInfo id={value} isWidget={false} />;
- default:
- return renderClickCount<DeadRageSelectorItem>(column, dataRow);
- }
- },
- [queryPrefix]
- );
- const selectorEmptyMessage = (
- <MessageContainer>
- <Title>{t('No dead or rage clicks found')}</Title>
- <Subtitle>
- {t(
- 'There were no dead or rage clicks within this timeframe. Expand your timeframe, or increase your replay sample rate to see more data.'
- )}
- </Subtitle>
- </MessageContainer>
- );
- return (
- <GridEditable
- error={isError}
- isLoading={isLoading}
- data={data ?? []}
- columnOrder={columns}
- emptyMessage={selectorEmptyMessage}
- columnSortBy={[]}
- stickyHeader
- grid={{
- onResizeColumn: handleResizeColumn,
- renderHeadCell,
- renderBodyCell,
- }}
- location={location as Location<any>}
- title={title}
- />
- );
- }
- export function SelectorLink({
- value,
- selectorQuery,
- projectId,
- }: {
- projectId: string;
- selectorQuery: string;
- value: string;
- }) {
- const organization = useOrganization();
- const location = useLocation();
- const hovercardContent = (
- <TooltipContainer>
- {t('Search for replays with clicks on the element')}
- <SelectorScroll>
- <CodeSnippet hideCopyButton language="javascript">
- {value}
- </CodeSnippet>
- </SelectorScroll>
- </TooltipContainer>
- );
- return (
- <StyledTextOverflow>
- <WiderHovercard position="right" body={hovercardContent}>
- <Link
- to={{
- pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
- query: {
- ...location.query,
- query: selectorQuery,
- cursor: undefined,
- project: projectId,
- },
- }}
- >
- <TextOverflow>{value}</TextOverflow>
- </Link>
- </WiderHovercard>
- </StyledTextOverflow>
- );
- }
- function renderClickCount<T>(column: GridColumnOrder<string>, dataRow: T) {
- const color = column.key === 'count_dead_clicks' ? 'yellow300' : 'red300';
- return (
- <ClickCount>
- <IconCursorArrow size="xs" color={color} />
- {dataRow[column.key]}
- </ClickCount>
- );
- }
- const ClickCount = styled(TextOverflow)`
- color: ${p => p.theme.gray400};
- display: grid;
- grid-template-columns: auto auto;
- gap: ${space(0.75)};
- align-items: center;
- justify-content: start;
- `;
- const StyledTextOverflow = styled(TextOverflow)`
- color: ${p => p.theme.blue300};
- `;
- const TooltipContainer = styled('div')`
- display: grid;
- grid-auto-flow: row;
- gap: ${space(1)};
- `;
- const SelectorScroll = styled('div')`
- overflow: scroll;
- `;
- const Subtitle = styled('div')`
- font-size: ${p => p.theme.fontSizeMedium};
- `;
- const Title = styled('div')`
- font-size: 24px;
- `;
- const MessageContainer = styled('div')`
- display: grid;
- grid-auto-flow: row;
- gap: ${space(1)};
- justify-items: center;
- `;
- const WidgetProjectContainer = styled('div')`
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: ${space(0.75)};
- `;
- const IndexProjectContainer = styled(WidgetProjectContainer)`
- padding-right: ${space(1)};
- `;
|