index.tsx 9.9 KB

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