import {useEffect, 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 ErrorBoundary from 'sentry/components/errorBoundary'; import EventDataSection from 'sentry/components/events/eventDataSection'; import {t} from 'sentry/locale'; import {Organization} from 'sentry/types'; import {BreadcrumbLevelType, Crumb, RawCrumb} from 'sentry/types/breadcrumbs'; import {Event} from 'sentry/types/event'; import {defined} from 'sentry/utils'; import SearchBarAction from '../searchBarAction'; import Level from './breadcrumb/level'; import Type from './breadcrumb/type'; import Breadcrumbs from './breadcrumbs'; import {getVirtualCrumb, transformCrumbs} from './utils'; type FilterOptions = NonNullable< React.ComponentProps['filterOptions'] >; type FilterOptionWithLevels = FilterOptions[0] & {levels?: BreadcrumbLevelType[]}; type Props = Pick, 'route' | 'router'> & { data: { values: Array; }; event: Event; organization: Organization; type: string; }; type State = { breadcrumbs: Crumb[]; displayRelativeTime: boolean; filterOptions: FilterOptions; filterSelections: FilterOptions; filteredByFilter: Crumb[]; filteredBySearch: Crumb[]; searchTerm: string; relativeTime?: string; }; function BreadcrumbsContainer({ data, event, organization, type: eventType, route, router, }: Props) { const [state, setState] = useState({ searchTerm: '', breadcrumbs: [], filteredByFilter: [], filteredBySearch: [], filterOptions: [], filterSelections: [], displayRelativeTime: false, }); const { filterOptions, breadcrumbs, searchTerm, filteredBySearch, displayRelativeTime, relativeTime, filteredByFilter, } = state; useEffect(() => { loadBreadcrumbs(); }, []); // eslint-disable-line react-hooks/exhaustive-deps function loadBreadcrumbs() { 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]; } const transformedCrumbs = transformCrumbs(crumbs); setState({ ...state, relativeTime: transformedCrumbs[transformedCrumbs.length - 1]?.timestamp, breadcrumbs: transformedCrumbs, filteredByFilter: transformedCrumbs, filteredBySearch: transformedCrumbs, filterOptions: getFilterOptions(transformedCrumbs), }); } function getFilterOptions(crumbs: ReturnType) { const typeOptions = getFilterTypes(crumbs); const levels = getFilterLevels(typeOptions); const options: FilterOptions = []; if (!!typeOptions.length) { options.push({ value: 'types', label: t('Types'), options: typeOptions.map(typeOption => omit(typeOption, 'levels')), }); } if (!!levels.length) { options.push({ value: 'levels', label: t('Levels'), options: levels, }); } return options; } function getFilterTypes(crumbs: ReturnType) { const filterTypes: FilterOptionWithLevels[] = []; 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: , 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: FilterOptionWithLevels[]) { const filterLevels: FilterOptions = []; 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}`, label: ( ), }); } } return filterLevels; } function filterBySearch(newSearchTerm: string, crumbs: Crumb[]) { if (!newSearchTerm.trim()) { return crumbs; } // 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 crumbs.filter(obj => Object.keys( pick(obj, ['type', 'category', 'message', 'level', 'timestamp', 'data']) ).some(key => { const info = obj[key]; if (!defined(info) || !String(info).trim()) { return false; } return JSON.stringify(info) .replace(/((^")|("$))/g, '') .toLocaleLowerCase() .trim() .includes(searchFor); }) ); } function getFilteredCrumbsByFilter(selectedFilterOptions: FilterOptions) { 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( filteredCrumb => checkedTypeOptions.has(filteredCrumb.type) && checkedLevelOptions.has(filteredCrumb.level) ); } if (!![...checkedTypeOptions].length) { return breadcrumbs.filter(filteredCrumb => checkedTypeOptions.has(filteredCrumb.type) ); } if (!![...checkedLevelOptions].length) { return breadcrumbs.filter(filteredCrumb => checkedLevelOptions.has(filteredCrumb.level) ); } return breadcrumbs; } function handleSearch(value: string) { setState({ ...state, searchTerm: value, filteredBySearch: filterBySearch(value, filteredByFilter), }); } function handleFilter(newfilterOptions: FilterOptions) { const newfilteredByFilter = getFilteredCrumbsByFilter(newfilterOptions); setState({ ...state, filterSelections: newfilterOptions, filteredByFilter: newfilteredByFilter, filteredBySearch: filterBySearch(searchTerm, newfilteredByFilter), }); } function handleSwitchTimeFormat() { setState({ ...state, displayRelativeTime: !displayRelativeTime, }); } function handleResetFilter() { setState({ ...state, filterSelections: [], filteredByFilter: breadcrumbs, filteredBySearch: filterBySearch(searchTerm, breadcrumbs), }); } function handleResetSearchBar() { setState({ ...state, searchTerm: '', filteredBySearch: breadcrumbs, }); } function getEmptyMessage() { if (!!filteredBySearch.length) { return {}; } if (searchTerm && !filteredBySearch.length) { const hasActiveFilter = state.filterSelections.length > 0; return { emptyMessage: t('Sorry, no breadcrumbs match your search query'), emptyAction: hasActiveFilter ? ( ) : ( ), }; } return { emptyMessage: t('There are no breadcrumbs to be displayed'), }; } return (

{t('Breadcrumbs')}

} actions={ } wrapTitle={false} isCentered >
); } export default BreadcrumbsContainer; const StyledSearchBarAction = styled(SearchBarAction)` z-index: 2; `; const LevelWrap = styled('span')` height: ${p => p.theme.text.lineHeightBody}em; display: flex; align-items: center; `;