Files
2024-02-15 17:01:50 +00:00

352 lines
10 KiB
TypeScript

import {useMemo, useState} from 'react';
import styled from '@emotion/styled';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import GuideAnchor from 'sentry/components/assistant/guideAnchor';
import {Button} from 'sentry/components/button';
import type {SelectOption, SelectSection} from 'sentry/components/compactSelect';
import {CompactSelect} from 'sentry/components/compactSelect';
import ErrorBoundary from 'sentry/components/errorBoundary';
import {EventDataSection} from 'sentry/components/events/eventDataSection';
import type {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types';
import {IconSort} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization} from 'sentry/types';
import type {BreadcrumbLevelType, RawCrumb} from 'sentry/types/breadcrumbs';
import type {Event} from 'sentry/types/event';
import {EntryType} from 'sentry/types/event';
import {defined} from 'sentry/utils';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
import SearchBarAction from '../searchBarAction';
import Level from './breadcrumb/level';
import Type from './breadcrumb/type';
import Breadcrumbs from './breadcrumbs';
import {getVirtualCrumb, transformCrumbs} from './utils';
type SelectOptionWithLevels = SelectOption<string> & {levels?: BreadcrumbLevelType[]};
type Props = {
data: {
values: Array<RawCrumb>;
};
event: Event;
organization: Organization;
hideTitle?: boolean;
};
enum BreadcrumbSort {
NEWEST = 'newest',
OLDEST = 'oldest',
}
const EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY = 'event-breadcrumb-sort';
const sortOptions = [
{label: t('Newest'), value: BreadcrumbSort.NEWEST},
{label: t('Oldest'), value: BreadcrumbSort.OLDEST},
];
function BreadcrumbsContainer({data, event, organization, hideTitle = false}: Props) {
const [searchTerm, setSearchTerm] = useState('');
const [filterSelections, setFilterSelections] = useState<SelectOption<string>[]>([]);
const [displayRelativeTime, setDisplayRelativeTime] = useState(false);
const [sort, setSort] = useLocalStorageState<BreadcrumbSort>(
EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY,
BreadcrumbSort.NEWEST
);
const entryIndex = event.entries.findIndex(
entry => entry.type === EntryType.BREADCRUMBS
);
const initialBreadcrumbs = useMemo(() => {
let crumbs = data.values;
// Add the (virtual) breadcrumb based on the error or message event if possible.
const virtualCrumb = getVirtualCrumb(event);
if (virtualCrumb) {
crumbs = [...crumbs, virtualCrumb];
}
return transformCrumbs(crumbs);
}, [data, event]);
const relativeTime = useMemo(() => {
return initialBreadcrumbs[initialBreadcrumbs.length - 1]?.timestamp ?? '';
}, [initialBreadcrumbs]);
const filterOptions = useMemo(() => {
const typeOptions = getFilterTypes(initialBreadcrumbs);
const levels = getFilterLevels(typeOptions);
const options: SelectSection<string>[] = [];
if (typeOptions.length) {
options.push({
key: 'types',
label: t('Types'),
options: typeOptions.map(typeOption => omit(typeOption, 'levels')),
});
}
if (levels.length) {
options.push({
key: 'levels',
label: t('Levels'),
options: levels,
});
}
return options;
}, [initialBreadcrumbs]);
function getFilterTypes(crumbs: ReturnType<typeof transformCrumbs>) {
const filterTypes: SelectOptionWithLevels[] = [];
for (const index in crumbs) {
const breadcrumb = crumbs[index];
const foundFilterType = filterTypes.findIndex(
f => f.value === `type-${breadcrumb.type}`
);
if (foundFilterType === -1) {
filterTypes.push({
value: `type-${breadcrumb.type}`,
leadingItems: <Type type={breadcrumb.type} color={breadcrumb.color} />,
label: breadcrumb.description,
levels: breadcrumb?.level ? [breadcrumb.level] : [],
});
continue;
}
if (
breadcrumb?.level &&
!filterTypes[foundFilterType].levels?.includes(breadcrumb.level)
) {
filterTypes[foundFilterType].levels?.push(breadcrumb.level);
}
}
return filterTypes;
}
function getFilterLevels(types: SelectOptionWithLevels[]) {
const filterLevels: SelectOption<string>[] = [];
for (const indexType in types) {
for (const indexLevel in types[indexType].levels) {
const level = types[indexType].levels?.[indexLevel];
if (filterLevels.some(f => f.value === `level-${level}`)) {
continue;
}
filterLevels.push({
value: `level-${level}`,
textValue: level,
label: (
<LevelWrap>
<Level level={level} />
</LevelWrap>
),
});
}
}
return filterLevels;
}
function applySearchTerm(breadcrumbs: BreadcrumbWithMeta[], newSearchTerm: string) {
if (!newSearchTerm.trim()) {
return breadcrumbs;
}
// Slightly hacky, but it works
// the string is being `stringify`d here in order to match exactly the same `stringify`d string of the loop
const searchFor = JSON.stringify(newSearchTerm)
// it replaces double backslash generate by JSON.stringify with single backslash
.replace(/((^")|("$))/g, '')
.toLocaleLowerCase();
return breadcrumbs.filter(({breadcrumb}) =>
Object.keys(
pick(breadcrumb, ['type', 'category', 'message', 'level', 'timestamp', 'data'])
).some(key => {
const info = breadcrumb[key];
if (!defined(info) || !String(info).trim()) {
return false;
}
return JSON.stringify(info)
.replace(/((^")|("$))/g, '')
.toLocaleLowerCase()
.trim()
.includes(searchFor);
})
);
}
function applySelectedFilters(
breadcrumbs: BreadcrumbWithMeta[],
selectedFilterOptions: SelectOption<string>[]
) {
const checkedTypeOptions = new Set(
selectedFilterOptions
.filter(option => option.value.startsWith('type-'))
.map(option => option.value.split('-')[1])
);
const checkedLevelOptions = new Set(
selectedFilterOptions
.filter(option => option.value.startsWith('level-'))
.map(option => option.value.split('-')[1])
);
if (!![...checkedTypeOptions].length && !![...checkedLevelOptions].length) {
return breadcrumbs.filter(
({breadcrumb}) =>
checkedTypeOptions.has(breadcrumb.type) &&
checkedLevelOptions.has(breadcrumb.level)
);
}
if ([...checkedTypeOptions].length) {
return breadcrumbs.filter(({breadcrumb}) =>
checkedTypeOptions.has(breadcrumb.type)
);
}
if ([...checkedLevelOptions].length) {
return breadcrumbs.filter(({breadcrumb}) =>
checkedLevelOptions.has(breadcrumb.level)
);
}
return breadcrumbs;
}
const displayedBreadcrumbs = useMemo(() => {
const breadcrumbsWithMeta = initialBreadcrumbs.map((breadcrumb, index) => ({
breadcrumb,
meta: event._meta?.entries?.[entryIndex]?.data?.values?.[index],
}));
const filteredBreadcrumbs = applySearchTerm(
applySelectedFilters(breadcrumbsWithMeta, filterSelections),
searchTerm
);
// Breadcrumbs come back from API sorted oldest -> newest.
// Need to `reverse()` instead of sort by timestamp because crumbs with
// exact same timestamp will appear out of order.
return sort === BreadcrumbSort.NEWEST
? [...filteredBreadcrumbs].reverse()
: filteredBreadcrumbs;
}, [
entryIndex,
event._meta?.entries,
filterSelections,
initialBreadcrumbs,
searchTerm,
sort,
]);
function getEmptyMessage() {
if (displayedBreadcrumbs.length) {
return {};
}
if (searchTerm && !displayedBreadcrumbs.length) {
const hasActiveFilter = filterSelections.length > 0;
return {
emptyMessage: t('Sorry, no breadcrumbs match your search query'),
emptyAction: hasActiveFilter ? (
<Button onClick={() => setFilterSelections([])} priority="primary">
{t('Reset filter')}
</Button>
) : (
<Button onClick={() => setSearchTerm('')} priority="primary">
{t('Clear search bar')}
</Button>
),
};
}
return {
emptyMessage: t('There are no breadcrumbs to be displayed'),
};
}
const actions = (
<SearchAndSortWrapper>
<SearchBarAction
placeholder={t('Search breadcrumbs')}
onChange={setSearchTerm}
query={searchTerm}
filterOptions={filterOptions}
filterSelections={filterSelections}
onFilterChange={setFilterSelections}
/>
<CompactSelect
triggerProps={{
icon: <IconSort />,
size: 'sm',
}}
onChange={selectedOption => {
setSort(selectedOption.value);
}}
value={sort}
options={sortOptions}
/>
</SearchAndSortWrapper>
);
return (
<EventDataSection
showPermalink={!hideTitle}
type={EntryType.BREADCRUMBS}
title={hideTitle ? '' : t('Breadcrumbs')}
actions={actions}
>
<ErrorBoundary>
<GuideAnchor target="breadcrumbs" position="bottom">
<Breadcrumbs
emptyMessage={getEmptyMessage()}
breadcrumbs={displayedBreadcrumbs}
event={event}
organization={organization}
onSwitchTimeFormat={() => setDisplayRelativeTime(old => !old)}
displayRelativeTime={displayRelativeTime}
searchTerm={searchTerm}
relativeTime={relativeTime}
/>
</GuideAnchor>
</ErrorBoundary>
</EventDataSection>
);
}
export {BreadcrumbsContainer as Breadcrumbs};
const SearchAndSortWrapper = styled('div')`
display: grid;
grid-template-columns: 1fr auto;
gap: ${space(1)};
@media (max-width: ${p => p.theme.breakpoints.small}) {
grid-template-columns: 1fr;
}
`;
const LevelWrap = styled('span')`
height: ${p => p.theme.text.lineHeightBody}em;
display: flex;
align-items: center;
`;