index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import {useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import omit from 'lodash/omit';
  4. import pick from 'lodash/pick';
  5. import {Button} from 'sentry/components/button';
  6. import type {SelectOption, SelectSection} from 'sentry/components/compactSelect';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import ErrorBoundary from 'sentry/components/errorBoundary';
  9. import type {EnhancedCrumb} from 'sentry/components/events/breadcrumbs/utils';
  10. import type {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types';
  11. import {IconSort} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {BreadcrumbLevelType, RawCrumb} from 'sentry/types/breadcrumbs';
  15. import type {Event} from 'sentry/types/event';
  16. import {EntryType} from 'sentry/types/event';
  17. import type {Organization} from 'sentry/types/organization';
  18. import {defined} from 'sentry/utils';
  19. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  20. import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
  21. import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
  22. import SearchBarAction from '../searchBarAction';
  23. import Level from './breadcrumb/level';
  24. import Type from './breadcrumb/type';
  25. import Breadcrumbs from './breadcrumbs';
  26. import {getVirtualCrumb, transformCrumbs} from './utils';
  27. type SelectOptionWithLevels = SelectOption<string> & {levels?: BreadcrumbLevelType[]};
  28. type Props = {
  29. data: {
  30. values: Array<RawCrumb>;
  31. };
  32. event: Event;
  33. organization: Organization;
  34. hideTitle?: boolean;
  35. };
  36. export enum BreadcrumbSort {
  37. NEWEST = 'newest',
  38. OLDEST = 'oldest',
  39. }
  40. export const BREADCRUMB_SORT_LOCALSTORAGE_KEY = 'event-breadcrumb-sort';
  41. export const BREADCRUMB_SORT_OPTIONS = [
  42. {label: t('Newest'), value: BreadcrumbSort.NEWEST},
  43. {label: t('Oldest'), value: BreadcrumbSort.OLDEST},
  44. ];
  45. type BreadcrumbListType = BreadcrumbWithMeta | EnhancedCrumb;
  46. export function applyBreadcrumbSearch<T extends BreadcrumbListType>(
  47. breadcrumbs: T[],
  48. newSearchTerm: string
  49. ): T[] {
  50. if (!newSearchTerm.trim()) {
  51. return breadcrumbs;
  52. }
  53. // Slightly hacky, but it works
  54. // the string is being `stringify`d here in order to match exactly the same `stringify`d string of the loop
  55. const searchFor = JSON.stringify(newSearchTerm)
  56. // it replaces double backslash generate by JSON.stringify with single backslash
  57. .replace(/((^")|("$))/g, '')
  58. .toLocaleLowerCase();
  59. return breadcrumbs.filter(({breadcrumb}) =>
  60. Object.keys(
  61. pick(breadcrumb, ['type', 'category', 'message', 'level', 'timestamp', 'data'])
  62. ).some(key => {
  63. const info = breadcrumb[key];
  64. if (!defined(info) || !String(info).trim()) {
  65. return false;
  66. }
  67. return JSON.stringify(info)
  68. .replace(/((^")|("$))/g, '')
  69. .toLocaleLowerCase()
  70. .trim()
  71. .includes(searchFor);
  72. })
  73. );
  74. }
  75. function BreadcrumbsContainer({data, event, organization, hideTitle = false}: Props) {
  76. const [searchTerm, setSearchTerm] = useState('');
  77. const [filterSelections, setFilterSelections] = useState<SelectOption<string>[]>([]);
  78. const [displayRelativeTime, setDisplayRelativeTime] = useState(false);
  79. const [sort, setSort] = useLocalStorageState<BreadcrumbSort>(
  80. BREADCRUMB_SORT_LOCALSTORAGE_KEY,
  81. BreadcrumbSort.NEWEST
  82. );
  83. const entryIndex = event.entries.findIndex(
  84. entry => entry.type === EntryType.BREADCRUMBS
  85. );
  86. const initialBreadcrumbs = useMemo(() => {
  87. let crumbs = data.values;
  88. // Add the (virtual) breadcrumb based on the error or message event if possible.
  89. const virtualCrumb = getVirtualCrumb(event);
  90. if (virtualCrumb) {
  91. crumbs = [...crumbs, virtualCrumb];
  92. }
  93. return transformCrumbs(crumbs);
  94. }, [data, event]);
  95. const relativeTime = useMemo(() => {
  96. return initialBreadcrumbs[initialBreadcrumbs.length - 1]?.timestamp ?? '';
  97. }, [initialBreadcrumbs]);
  98. const filterOptions = useMemo(() => {
  99. const typeOptions = getFilterTypes(initialBreadcrumbs);
  100. const levels = getFilterLevels(typeOptions);
  101. const options: SelectSection<string>[] = [];
  102. if (typeOptions.length) {
  103. options.push({
  104. key: 'types',
  105. label: t('Types'),
  106. options: typeOptions.map(typeOption => omit(typeOption, 'levels')),
  107. });
  108. }
  109. if (levels.length) {
  110. options.push({
  111. key: 'levels',
  112. label: t('Levels'),
  113. options: levels,
  114. });
  115. }
  116. return options;
  117. }, [initialBreadcrumbs]);
  118. function getFilterTypes(crumbs: ReturnType<typeof transformCrumbs>) {
  119. const filterTypes: SelectOptionWithLevels[] = [];
  120. for (const index in crumbs) {
  121. const breadcrumb = crumbs[index];
  122. const foundFilterType = filterTypes.findIndex(
  123. f => f.value === `type-${breadcrumb.type}`
  124. );
  125. if (foundFilterType === -1) {
  126. filterTypes.push({
  127. value: `type-${breadcrumb.type}`,
  128. leadingItems: <Type type={breadcrumb.type} color={breadcrumb.color} />,
  129. label: breadcrumb.description,
  130. levels: breadcrumb?.level ? [breadcrumb.level] : [],
  131. });
  132. continue;
  133. }
  134. if (
  135. breadcrumb?.level &&
  136. !filterTypes[foundFilterType].levels?.includes(breadcrumb.level)
  137. ) {
  138. filterTypes[foundFilterType].levels?.push(breadcrumb.level);
  139. }
  140. }
  141. return filterTypes;
  142. }
  143. function getFilterLevels(types: SelectOptionWithLevels[]) {
  144. const filterLevels: SelectOption<string>[] = [];
  145. for (const indexType in types) {
  146. for (const indexLevel in types[indexType].levels) {
  147. const level = types[indexType].levels?.[indexLevel];
  148. if (filterLevels.some(f => f.value === `level-${level}`)) {
  149. continue;
  150. }
  151. filterLevels.push({
  152. value: `level-${level}`,
  153. textValue: level,
  154. label: (
  155. <LevelWrap>
  156. <Level level={level} />
  157. </LevelWrap>
  158. ),
  159. });
  160. }
  161. }
  162. return filterLevels;
  163. }
  164. function applySelectedFilters(
  165. breadcrumbs: BreadcrumbWithMeta[],
  166. selectedFilterOptions: SelectOption<string>[]
  167. ) {
  168. const checkedTypeOptions = new Set(
  169. selectedFilterOptions
  170. .filter(option => option.value.startsWith('type-'))
  171. .map(option => option.value.split('-')[1])
  172. );
  173. const checkedLevelOptions = new Set(
  174. selectedFilterOptions
  175. .filter(option => option.value.startsWith('level-'))
  176. .map(option => option.value.split('-')[1])
  177. );
  178. if (!![...checkedTypeOptions].length && !![...checkedLevelOptions].length) {
  179. return breadcrumbs.filter(
  180. ({breadcrumb}) =>
  181. checkedTypeOptions.has(breadcrumb.type) &&
  182. checkedLevelOptions.has(breadcrumb.level)
  183. );
  184. }
  185. if ([...checkedTypeOptions].length) {
  186. return breadcrumbs.filter(({breadcrumb}) =>
  187. checkedTypeOptions.has(breadcrumb.type)
  188. );
  189. }
  190. if ([...checkedLevelOptions].length) {
  191. return breadcrumbs.filter(({breadcrumb}) =>
  192. checkedLevelOptions.has(breadcrumb.level)
  193. );
  194. }
  195. return breadcrumbs;
  196. }
  197. const displayedBreadcrumbs = useMemo(() => {
  198. const breadcrumbsWithMeta = initialBreadcrumbs.map((breadcrumb, index) => ({
  199. breadcrumb,
  200. meta: event._meta?.entries?.[entryIndex]?.data?.values?.[index],
  201. }));
  202. const filteredBreadcrumbs = applyBreadcrumbSearch(
  203. applySelectedFilters(breadcrumbsWithMeta, filterSelections),
  204. searchTerm
  205. );
  206. // Breadcrumbs come back from API sorted oldest -> newest.
  207. // Need to `reverse()` instead of sort by timestamp because crumbs with
  208. // exact same timestamp will appear out of order.
  209. return sort === BreadcrumbSort.NEWEST
  210. ? [...filteredBreadcrumbs].reverse()
  211. : filteredBreadcrumbs;
  212. }, [
  213. entryIndex,
  214. event._meta?.entries,
  215. filterSelections,
  216. initialBreadcrumbs,
  217. searchTerm,
  218. sort,
  219. ]);
  220. function getEmptyMessage() {
  221. if (displayedBreadcrumbs.length) {
  222. return {};
  223. }
  224. if (searchTerm && !displayedBreadcrumbs.length) {
  225. const hasActiveFilter = filterSelections.length > 0;
  226. return {
  227. emptyMessage: t('Sorry, no breadcrumbs match your search query'),
  228. emptyAction: hasActiveFilter ? (
  229. <Button onClick={() => setFilterSelections([])} priority="primary">
  230. {t('Reset filter')}
  231. </Button>
  232. ) : (
  233. <Button onClick={() => setSearchTerm('')} priority="primary">
  234. {t('Clear search bar')}
  235. </Button>
  236. ),
  237. };
  238. }
  239. return {
  240. emptyMessage: t('There are no breadcrumbs to be displayed'),
  241. };
  242. }
  243. const actions = (
  244. <SearchAndSortWrapper>
  245. <SearchBarAction
  246. placeholder={t('Search breadcrumbs')}
  247. onChange={setSearchTerm}
  248. query={searchTerm}
  249. filterOptions={filterOptions}
  250. filterSelections={filterSelections}
  251. onFilterChange={setFilterSelections}
  252. />
  253. <CompactSelect
  254. triggerProps={{
  255. icon: <IconSort />,
  256. size: 'sm',
  257. }}
  258. onChange={selectedOption => {
  259. setSort(selectedOption.value);
  260. }}
  261. value={sort}
  262. options={BREADCRUMB_SORT_OPTIONS}
  263. />
  264. </SearchAndSortWrapper>
  265. );
  266. return (
  267. <InterimSection
  268. showPermalink={!hideTitle}
  269. type={SectionKey.BREADCRUMBS}
  270. title={hideTitle ? '' : t('Breadcrumbs')}
  271. actions={actions}
  272. >
  273. <ErrorBoundary>
  274. <Breadcrumbs
  275. emptyMessage={getEmptyMessage()}
  276. breadcrumbs={displayedBreadcrumbs}
  277. event={event}
  278. organization={organization}
  279. onSwitchTimeFormat={() => setDisplayRelativeTime(old => !old)}
  280. displayRelativeTime={displayRelativeTime}
  281. searchTerm={searchTerm}
  282. relativeTime={relativeTime}
  283. />
  284. </ErrorBoundary>
  285. </InterimSection>
  286. );
  287. }
  288. export {BreadcrumbsContainer as Breadcrumbs};
  289. export const SearchAndSortWrapper = styled('div')`
  290. display: grid;
  291. grid-template-columns: 1fr auto;
  292. gap: ${space(1)};
  293. @media (max-width: ${p => p.theme.breakpoints.small}) {
  294. grid-template-columns: 1fr;
  295. }
  296. `;
  297. const LevelWrap = styled('span')`
  298. height: ${p => p.theme.text.lineHeightBody}em;
  299. display: flex;
  300. align-items: center;
  301. `;