diff --git a/static/app/views/explore/components/suspectTags/charts.tsx b/static/app/views/explore/components/suspectTags/charts.tsx index 17dd4dade97e9d..c07ada7e8be6e4 100644 --- a/static/app/views/explore/components/suspectTags/charts.tsx +++ b/static/app/views/explore/components/suspectTags/charts.tsx @@ -1,7 +1,11 @@ -import {useLayoutEffect, useRef, useState} from 'react'; +import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {Theme} from '@emotion/react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Virtualizer} from '@tanstack/react-virtual'; +import {useVirtualizer} from '@tanstack/react-virtual'; +import type {TooltipComponentFormatterCallbackParams} from 'echarts'; +import type {CallbackDataParams} from 'echarts/types/dist/shared'; import BaseChart from 'sentry/components/charts/baseChart'; import {space} from 'sentry/styles/space'; @@ -15,26 +19,121 @@ const BASELINE_SERIES_NAME = 'baseline'; type Props = { rankedAttributes: SuspectAttributesResult['rankedAttributes']; + searchQuery: string; }; // TODO Abdullah Khan: Add virtualization and search to the list of charts -export function Charts({rankedAttributes}: Props) { +export function Charts({rankedAttributes, searchQuery}: Props) { const theme = useTheme(); + const scrollContainerRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: rankedAttributes.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 200, + overscan: 5, + }); + + const virtualItems = virtualizer.getVirtualItems(); + return ( - - {rankedAttributes.map(attribute => ( - - ))} + + + {virtualItems.map(item => ( + + + + ))} + ); } +type CohortData = SuspectAttributesResult['rankedAttributes'][number]['cohort1']; + +function cohortsToSeriesData( + cohort1: CohortData, + cohort2: CohortData +): { + [BASELINE_SERIES_NAME]: Array<{label: string; value: string}>; + [SELECTED_SERIES_NAME]: Array<{label: string; value: string}>; +} { + const cohort1Map = new Map(cohort1.map(({label, value}) => [label, value])); + const cohort2Map = new Map(cohort2.map(({label, value}) => [label, value])); + + const uniqueLabels = new Set([ + ...cohort1.map(c => c.label), + ...cohort2.map(c => c.label), + ]); + + // From the unique labels, we create two series data objects, one for the selected cohort and one for the baseline cohort. + // If a label isn't present in either of the cohorts, we assign a value of 0, to that label in the respective series. + const seriesData = Array.from(uniqueLabels).map(label => { + const selectedVal = cohort1Map.get(label) ?? '0'; + const baselineVal = cohort2Map.get(label) ?? '0'; + + // We sort by descending value of the selected cohort + const sortVal = Number(selectedVal); + + return { + label, + selectedValue: selectedVal, + baselineValue: baselineVal, + sortValue: sortVal, + }; + }); + + seriesData.sort((a, b) => b.sortValue - a.sortValue); + + const selectedSeriesData = seriesData.map(({label, selectedValue}) => ({ + label, + value: selectedValue, + })); + + const baselineSeriesData = seriesData.map(({label, baselineValue}) => ({ + label, + value: baselineValue, + })); + + return { + [SELECTED_SERIES_NAME]: selectedSeriesData, + [BASELINE_SERIES_NAME]: baselineSeriesData, + }; +} + +// TODO Abdullah Khan: This is a temporary function to get the totals of the cohorts. Will be removed +// once the backend returns the totals. +function cohortTotals( + cohort1: CohortData, + cohort2: CohortData +): { + [BASELINE_SERIES_NAME]: number; + [SELECTED_SERIES_NAME]: number; +} { + const cohort1Total = cohort1.reduce((acc, curr) => acc + Number(curr.value), 0); + const cohort2Total = cohort2.reduce((acc, curr) => acc + Number(curr.value), 0); + return { + [SELECTED_SERIES_NAME]: cohort1Total, + [BASELINE_SERIES_NAME]: cohort2Total, + }; +} + function Chart({ attribute, theme, + index, + virtualizer, }: { attribute: SuspectAttributesResult['rankedAttributes'][number]; + index: number; theme: Theme; + virtualizer: Virtualizer; }) { const chartRef = useRef(null); const [hideLabels, setHideLabels] = useState(false); @@ -42,6 +141,82 @@ function Chart({ const cohort1Color = theme.chart.getColorPalette(0)?.[0]; const cohort2Color = '#dddddd'; + const seriesData = useMemo( + () => cohortsToSeriesData(attribute.cohort1, attribute.cohort2), + [attribute.cohort1, attribute.cohort2] + ); + + const seriesTotals = useMemo( + () => cohortTotals(attribute.cohort1, attribute.cohort2), + [attribute.cohort1, attribute.cohort2] + ); + + const valueFormatter = useCallback( + (_value: number, label?: string, seriesParams?: CallbackDataParams) => { + const data = Number(seriesParams?.data); + const total = seriesTotals[label as keyof typeof seriesTotals]; + + if (total === 0) { + return '\u2014'; + } + + const percentage = (data / total) * 100; + return `${percentage.toFixed(1)}%`; + }, + [seriesTotals] + ); + + const formatAxisLabel = useCallback( + ( + _value: number, + _isTimestamp: boolean, + _utc: boolean, + _showTimeInTooltip: boolean, + _addSecondsToTimeFormat: boolean, + _bucketSize: number | undefined, + seriesParamsOrParam: TooltipComponentFormatterCallbackParams + ) => { + if (!Array.isArray(seriesParamsOrParam)) { + return '\u2014'; + } + + const selectedParam = seriesParamsOrParam.find( + s => s.seriesName === SELECTED_SERIES_NAME + ); + const baselineParam = seriesParamsOrParam.find( + s => s.seriesName === BASELINE_SERIES_NAME + ); + + if (!selectedParam || !baselineParam) { + throw new Error('selectedParam or baselineParam is not defined'); + } + + const selectedTotal = + seriesTotals[selectedParam?.seriesName as keyof typeof seriesTotals]; + const selectedData = Number(selectedParam?.data); + const selectedPercentage = + selectedTotal === 0 ? 0 : (selectedData / selectedTotal) * 100; + + const baselineTotal = + seriesTotals[baselineParam?.seriesName as keyof typeof seriesTotals]; + const baselineData = Number(baselineParam?.data); + const baselinePercentage = + baselineTotal === 0 ? 0 : (baselineData / baselineTotal) * 100; + + const isDifferent = selectedPercentage.toFixed(1) !== baselinePercentage.toFixed(1); + + const status = isDifferent + ? {adjective: 'different', message: 'This is suspicious.'} + : {adjective: 'similar', message: 'Nothing unusual here.'}; + + const name = selectedParam?.name ?? baselineParam?.name ?? ''; + const truncatedName = name.length > 300 ? `${name.slice(0, 300)}...` : name; + + return `
${truncatedName} is ${status.adjective} ${isDifferent ? 'between' : 'across'} selected and baseline data. ${status.message}
`; + }, + [seriesTotals, theme.textColor] + ); + useLayoutEffect(() => { const chartContainer = chartRef.current?.getEchartsInstance().getDom(); if (!chartContainer) return; @@ -62,87 +237,103 @@ function Chart({ }, [attribute]); return ( - - {attribute.attributeName} - cohort.label), - truncate: 14, - axisLabel: hideLabels - ? {show: false} - : { - hideOverlap: true, - showMaxLabel: false, - showMinLabel: false, - color: '#000', - interval: 0, - formatter: (value: string) => value, +
+ + {attribute.attributeName} + cohort.label), + truncate: 14, + axisLabel: hideLabels + ? {show: false} + : { + hideOverlap: true, + showMaxLabel: false, + showMinLabel: false, + color: '#000', + interval: 0, + formatter: (value: string) => value, + }, + }} + yAxis={{ + type: 'value', + axisLabel: { + show: false, + width: 0, + }, + }} + series={[ + { + type: 'bar', + data: seriesData[SELECTED_SERIES_NAME].map(cohort => cohort.value), + name: SELECTED_SERIES_NAME, + itemStyle: { + color: cohort1Color, }, - }} - yAxis={{ - type: 'value', - axisLabel: { - show: false, - width: 0, - }, - }} - series={[ - { - type: 'bar', - data: attribute.cohort1.map(cohort => cohort.value), - name: SELECTED_SERIES_NAME, - itemStyle: { - color: cohort1Color, + barMaxWidth: MAX_BAR_WIDTH, + animation: false, }, - barMaxWidth: MAX_BAR_WIDTH, - animation: false, - }, - { - type: 'bar', - data: attribute.cohort2.map(cohort => cohort.value), - name: BASELINE_SERIES_NAME, - itemStyle: { - color: cohort2Color, + { + type: 'bar', + data: seriesData[BASELINE_SERIES_NAME].map(cohort => cohort.value), + name: BASELINE_SERIES_NAME, + itemStyle: { + color: cohort2Color, + }, + barMaxWidth: MAX_BAR_WIDTH, + animation: false, }, - barMaxWidth: MAX_BAR_WIDTH, - animation: false, - }, - ]} - /> - + ]} + /> + +
); } const ChartsWrapper = styled('div')` - flex: 1; + height: 100%; overflow: auto; overflow-y: scroll; overscroll-behavior: none; `; +const AllItemsContainer = styled('div')<{height: number}>` + position: relative; + width: 100%; + height: ${p => p.height}px; +`; + +const VirtualOffset = styled('div')<{offset: number}>` + position: absolute; + top: 0; + left: 0; + width: 100%; + transform: translateY(${p => p.offset}px); +`; + const ChartWrapper = styled('div')` display: flex; flex-direction: column; height: 200px; - padding: ${space(2)} ${space(2)} 0 ${space(2)}; - - &:not(:last-child) { - border-bottom: 1px solid ${p => p.theme.border}; - } + padding-top: ${space(1.5)}; + border-top: 1px solid ${p => p.theme.border}; `; const ChartTitle = styled('div')` diff --git a/static/app/views/explore/components/suspectTags/drawer.tsx b/static/app/views/explore/components/suspectTags/drawer.tsx index 8754bc9fdd75b8..9c77b727049ea5 100644 --- a/static/app/views/explore/components/suspectTags/drawer.tsx +++ b/static/app/views/explore/components/suspectTags/drawer.tsx @@ -1,10 +1,13 @@ +import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; +import BaseSearchBar from 'sentry/components/searchBar'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import type {ChartInfo} from 'sentry/views/explore/charts'; import {Charts} from 'sentry/views/explore/components/suspectTags/charts'; import type {BoxSelectOptions} from 'sentry/views/explore/hooks/useChartBoxSelect'; @@ -17,6 +20,29 @@ type Props = { export function Drawer({boxSelectOptions, chartInfo}: Props) { const {data, isLoading, isError} = useSuspectAttributes({boxSelectOptions, chartInfo}); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredRankedAttributes = useMemo(() => { + const attrs = data?.rankedAttributes; + if (!attrs) { + return []; + } + + if (!searchQuery.trim()) { + return attrs; + } + + const searchFor = searchQuery.toLocaleLowerCase().trim(); + + return attrs.filter(attr => + attr.attributeName.toLocaleLowerCase().trim().includes(searchFor) + ); + }, [searchQuery, data?.rankedAttributes]); + + // We use the search query as a key to virtual list items, to correctly re-mount + // charts that were invisible before the user searched for it. Debouncing the search + // query here to ensure smooth typing, by delaying the re-mounts a little as the user types. + const debouncedSearchQuery = useDebouncedValue(searchQuery, 100); return ( @@ -33,7 +59,24 @@ export function Drawer({boxSelectOptions, chartInfo}: Props) { ) : isError ? ( ) : ( - + + setSearchQuery(query)} + query={searchQuery} + size="sm" + /> + {filteredRankedAttributes.length > 0 ? ( + + ) : ( + + {t('No matching attributes found')} + + )} + )} @@ -41,18 +84,23 @@ export function Drawer({boxSelectOptions, chartInfo}: Props) { } const Title = styled('h4')` - margin: 0; + margin-bottom: ${space(0.5)}; flex-shrink: 0; `; -const SubTitle = styled('span')``; +const StyledBaseSearchBar = styled(BaseSearchBar)` + margin-bottom: ${space(1.5)}; +`; + +const SubTitle = styled('span')` + margin-bottom: ${space(3)}; +`; const StyledDrawerBody = styled(DrawerBody)` flex: 1; min-height: 0; display: flex; flex-direction: column; - gap: ${space(1)}; `; const DrawerContainer = styled('div')` @@ -64,3 +112,11 @@ const DrawerContainer = styled('div')` flex-shrink: 0; } `; + +const NoAttributesMessage = styled('div')` + display: flex; + justify-content: center; + align-items: center; + margin-top: ${space(4)}; + color: ${p => p.theme.subText}; +`;