index.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import omit from 'lodash/omit';
  4. import pick from 'lodash/pick';
  5. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  6. import Button from 'sentry/components/button';
  7. import ErrorBoundary from 'sentry/components/errorBoundary';
  8. import EventDataSection from 'sentry/components/events/eventDataSection';
  9. import {t} from 'sentry/locale';
  10. import {Organization} from 'sentry/types';
  11. import {BreadcrumbLevelType, Crumb, RawCrumb} from 'sentry/types/breadcrumbs';
  12. import {Event} from 'sentry/types/event';
  13. import {defined} from 'sentry/utils';
  14. import SearchBarAction from '../searchBarAction';
  15. import Level from './breadcrumb/level';
  16. import Type from './breadcrumb/type';
  17. import Breadcrumbs from './breadcrumbs';
  18. import {getVirtualCrumb, transformCrumbs} from './utils';
  19. type FilterOptions = NonNullable<
  20. React.ComponentProps<typeof SearchBarAction>['filterOptions']
  21. >;
  22. type FilterOptionWithLevels = FilterOptions[0] & {levels?: BreadcrumbLevelType[]};
  23. type Props = Pick<React.ComponentProps<typeof Breadcrumbs>, 'route' | 'router'> & {
  24. data: {
  25. values: Array<RawCrumb>;
  26. };
  27. event: Event;
  28. organization: Organization;
  29. type: string;
  30. };
  31. type State = {
  32. breadcrumbs: Crumb[];
  33. displayRelativeTime: boolean;
  34. filterOptions: FilterOptions;
  35. filterSelections: FilterOptions;
  36. filteredByFilter: Crumb[];
  37. filteredBySearch: Crumb[];
  38. searchTerm: string;
  39. relativeTime?: string;
  40. };
  41. function BreadcrumbsContainer({
  42. data,
  43. event,
  44. organization,
  45. type: eventType,
  46. route,
  47. router,
  48. }: Props) {
  49. const [state, setState] = useState<State>({
  50. searchTerm: '',
  51. breadcrumbs: [],
  52. filteredByFilter: [],
  53. filteredBySearch: [],
  54. filterOptions: [],
  55. filterSelections: [],
  56. displayRelativeTime: false,
  57. });
  58. const {
  59. filterOptions,
  60. breadcrumbs,
  61. searchTerm,
  62. filteredBySearch,
  63. displayRelativeTime,
  64. relativeTime,
  65. filteredByFilter,
  66. } = state;
  67. useEffect(() => {
  68. loadBreadcrumbs();
  69. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  70. function loadBreadcrumbs() {
  71. let crumbs = data.values;
  72. // Add the (virtual) breadcrumb based on the error or message event if possible.
  73. const virtualCrumb = getVirtualCrumb(event);
  74. if (virtualCrumb) {
  75. crumbs = [...crumbs, virtualCrumb];
  76. }
  77. const transformedCrumbs = transformCrumbs(crumbs);
  78. setState({
  79. ...state,
  80. relativeTime: transformedCrumbs[transformedCrumbs.length - 1]?.timestamp,
  81. breadcrumbs: transformedCrumbs,
  82. filteredByFilter: transformedCrumbs,
  83. filteredBySearch: transformedCrumbs,
  84. filterOptions: getFilterOptions(transformedCrumbs),
  85. });
  86. }
  87. function getFilterOptions(crumbs: ReturnType<typeof transformCrumbs>) {
  88. const typeOptions = getFilterTypes(crumbs);
  89. const levels = getFilterLevels(typeOptions);
  90. const options: FilterOptions = [];
  91. if (!!typeOptions.length) {
  92. options.push({
  93. value: 'types',
  94. label: t('Types'),
  95. options: typeOptions.map(typeOption => omit(typeOption, 'levels')),
  96. });
  97. }
  98. if (!!levels.length) {
  99. options.push({
  100. value: 'levels',
  101. label: t('Levels'),
  102. options: levels,
  103. });
  104. }
  105. return options;
  106. }
  107. function getFilterTypes(crumbs: ReturnType<typeof transformCrumbs>) {
  108. const filterTypes: FilterOptionWithLevels[] = [];
  109. for (const index in crumbs) {
  110. const breadcrumb = crumbs[index];
  111. const foundFilterType = filterTypes.findIndex(
  112. f => f.value === `type-${breadcrumb.type}`
  113. );
  114. if (foundFilterType === -1) {
  115. filterTypes.push({
  116. value: `type-${breadcrumb.type}`,
  117. leadingItems: <Type type={breadcrumb.type} color={breadcrumb.color} />,
  118. label: breadcrumb.description,
  119. levels: breadcrumb?.level ? [breadcrumb.level] : [],
  120. });
  121. continue;
  122. }
  123. if (
  124. breadcrumb?.level &&
  125. !filterTypes[foundFilterType].levels?.includes(breadcrumb.level)
  126. ) {
  127. filterTypes[foundFilterType].levels?.push(breadcrumb.level);
  128. }
  129. }
  130. return filterTypes;
  131. }
  132. function getFilterLevels(types: FilterOptionWithLevels[]) {
  133. const filterLevels: FilterOptions = [];
  134. for (const indexType in types) {
  135. for (const indexLevel in types[indexType].levels) {
  136. const level = types[indexType].levels?.[indexLevel];
  137. if (filterLevels.some(f => f.value === `level-${level}`)) {
  138. continue;
  139. }
  140. filterLevels.push({
  141. value: `level-${level}`,
  142. label: (
  143. <LevelWrap>
  144. <Level level={level} />
  145. </LevelWrap>
  146. ),
  147. });
  148. }
  149. }
  150. return filterLevels;
  151. }
  152. function filterBySearch(newSearchTerm: string, crumbs: Crumb[]) {
  153. if (!newSearchTerm.trim()) {
  154. return crumbs;
  155. }
  156. // Slightly hacky, but it works
  157. // the string is being `stringify`d here in order to match exactly the same `stringify`d string of the loop
  158. const searchFor = JSON.stringify(newSearchTerm)
  159. // it replaces double backslash generate by JSON.stringify with single backslash
  160. .replace(/((^")|("$))/g, '')
  161. .toLocaleLowerCase();
  162. return crumbs.filter(obj =>
  163. Object.keys(
  164. pick(obj, ['type', 'category', 'message', 'level', 'timestamp', 'data'])
  165. ).some(key => {
  166. const info = obj[key];
  167. if (!defined(info) || !String(info).trim()) {
  168. return false;
  169. }
  170. return JSON.stringify(info)
  171. .replace(/((^")|("$))/g, '')
  172. .toLocaleLowerCase()
  173. .trim()
  174. .includes(searchFor);
  175. })
  176. );
  177. }
  178. function getFilteredCrumbsByFilter(selectedFilterOptions: FilterOptions) {
  179. const checkedTypeOptions = new Set(
  180. selectedFilterOptions
  181. .filter(option => option.value.startsWith('type-'))
  182. .map(option => option.value.split('-')[1])
  183. );
  184. const checkedLevelOptions = new Set(
  185. selectedFilterOptions
  186. .filter(option => option.value.startsWith('level-'))
  187. .map(option => option.value.split('-')[1])
  188. );
  189. if (!![...checkedTypeOptions].length && !![...checkedLevelOptions].length) {
  190. return breadcrumbs.filter(
  191. filteredCrumb =>
  192. checkedTypeOptions.has(filteredCrumb.type) &&
  193. checkedLevelOptions.has(filteredCrumb.level)
  194. );
  195. }
  196. if (!![...checkedTypeOptions].length) {
  197. return breadcrumbs.filter(filteredCrumb =>
  198. checkedTypeOptions.has(filteredCrumb.type)
  199. );
  200. }
  201. if (!![...checkedLevelOptions].length) {
  202. return breadcrumbs.filter(filteredCrumb =>
  203. checkedLevelOptions.has(filteredCrumb.level)
  204. );
  205. }
  206. return breadcrumbs;
  207. }
  208. function handleSearch(value: string) {
  209. setState({
  210. ...state,
  211. searchTerm: value,
  212. filteredBySearch: filterBySearch(value, filteredByFilter),
  213. });
  214. }
  215. function handleFilter(newfilterOptions: FilterOptions) {
  216. const newfilteredByFilter = getFilteredCrumbsByFilter(newfilterOptions);
  217. setState({
  218. ...state,
  219. filterSelections: newfilterOptions,
  220. filteredByFilter: newfilteredByFilter,
  221. filteredBySearch: filterBySearch(searchTerm, newfilteredByFilter),
  222. });
  223. }
  224. function handleSwitchTimeFormat() {
  225. setState({
  226. ...state,
  227. displayRelativeTime: !displayRelativeTime,
  228. });
  229. }
  230. function handleResetFilter() {
  231. setState({
  232. ...state,
  233. filterSelections: [],
  234. filteredByFilter: breadcrumbs,
  235. filteredBySearch: filterBySearch(searchTerm, breadcrumbs),
  236. });
  237. }
  238. function handleResetSearchBar() {
  239. setState({
  240. ...state,
  241. searchTerm: '',
  242. filteredBySearch: breadcrumbs,
  243. });
  244. }
  245. function getEmptyMessage() {
  246. if (!!filteredBySearch.length) {
  247. return {};
  248. }
  249. if (searchTerm && !filteredBySearch.length) {
  250. const hasActiveFilter = state.filterSelections.length > 0;
  251. return {
  252. emptyMessage: t('Sorry, no breadcrumbs match your search query'),
  253. emptyAction: hasActiveFilter ? (
  254. <Button onClick={handleResetFilter} priority="primary">
  255. {t('Reset filter')}
  256. </Button>
  257. ) : (
  258. <Button onClick={handleResetSearchBar} priority="primary">
  259. {t('Clear search bar')}
  260. </Button>
  261. ),
  262. };
  263. }
  264. return {
  265. emptyMessage: t('There are no breadcrumbs to be displayed'),
  266. };
  267. }
  268. return (
  269. <EventDataSection
  270. type={eventType}
  271. title={
  272. <GuideAnchor target="breadcrumbs" position="right">
  273. <h3>{t('Breadcrumbs')}</h3>
  274. </GuideAnchor>
  275. }
  276. actions={
  277. <StyledSearchBarAction
  278. placeholder={t('Search breadcrumbs')}
  279. onChange={handleSearch}
  280. query={searchTerm}
  281. filterOptions={filterOptions}
  282. filterSelections={state.filterSelections}
  283. onFilterChange={handleFilter}
  284. />
  285. }
  286. wrapTitle={false}
  287. isCentered
  288. >
  289. <ErrorBoundary>
  290. <Breadcrumbs
  291. router={router}
  292. route={route}
  293. emptyMessage={getEmptyMessage()}
  294. breadcrumbs={filteredBySearch}
  295. event={event}
  296. organization={organization}
  297. onSwitchTimeFormat={handleSwitchTimeFormat}
  298. displayRelativeTime={displayRelativeTime}
  299. searchTerm={searchTerm}
  300. relativeTime={relativeTime!} // relativeTime has to be always available, as the last item timestamp is the event created time
  301. />
  302. </ErrorBoundary>
  303. </EventDataSection>
  304. );
  305. }
  306. export default BreadcrumbsContainer;
  307. const StyledSearchBarAction = styled(SearchBarAction)`
  308. z-index: 2;
  309. `;
  310. const LevelWrap = styled('span')`
  311. height: ${p => p.theme.text.lineHeightBody}em;
  312. display: flex;
  313. align-items: center;
  314. `;