mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
268 lines
7.3 KiB
TypeScript
268 lines
7.3 KiB
TypeScript
import type {ReactNode} from 'react';
|
|
import {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 type {GridColumnOrder} from 'sentry/components/gridEditable';
|
|
import GridEditable 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 type {DeadRageSelectorItem} from 'sentry/views/replays/types';
|
|
import {WiderHovercard} from 'sentry/views/starfish/components/tableCells/spanDescriptionCell';
|
|
|
|
export interface UrlState {
|
|
widths: string[];
|
|
}
|
|
|
|
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;
|
|
text-align: center;
|
|
padding: ${space(4)};
|
|
`;
|
|
|
|
const WidgetProjectContainer = styled('div')`
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: ${space(0.75)};
|
|
`;
|
|
|
|
const IndexProjectContainer = styled(WidgetProjectContainer)`
|
|
padding-right: ${space(1)};
|
|
`;
|