From 2420942e889f0dfdb4ff119bec90ca2bb2de40e1 Mon Sep 17 00:00:00 2001 From: Saurabh Mishra Date: Tue, 29 Sep 2026 00:52:52 +0000 Subject: [PATCH] [AI] Only show stacked bar tooltip truncation indicator when categories are hidden (#8917) * [AI] Only show stacked bar tooltip truncation indicator when categories are hidden * [AI] Cover the non-compact tooltip case and sharpen the release note --- .../reports/graphs/StackedBarGraph.test.tsx | 150 ++++++++++++++++++ .../reports/graphs/StackedBarGraph.tsx | 22 ++- ...tacked-bar-tooltip-truncation-indicator.md | 6 + 3 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 packages/desktop-client/src/components/reports/graphs/StackedBarGraph.test.tsx create mode 100644 upcoming-release-notes/stacked-bar-tooltip-truncation-indicator.md diff --git a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.test.tsx b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.test.tsx new file mode 100644 index 0000000000..8cf0d0718d --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.test.tsx @@ -0,0 +1,150 @@ +import React, { cloneElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { MemoryRouter } from 'react-router'; + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { TestProviders } from '#mocks'; + +type TooltipEntry = { + name: string; + value: number; + color: string; + payload: { name: string; color: string }; +}; + +// The tooltip is only reachable through recharts, which cannot measure or +// hover a chart in jsdom. Stubbing the chart primitives renders the tooltip +// content directly with the payload recharts would have handed it. +let tooltipPayload: TooltipEntry[] = []; + +vi.mock('recharts', () => ({ + Bar: () => null, + BarChart: ({ children }: { children: ReactNode }) =>
{children}
, + CartesianGrid: () => null, + LabelList: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: ({ + content, + }: { + content: ReactElement<{ + active?: boolean; + label?: string; + payload?: TooltipEntry[]; + }>; + }) => + cloneElement(content, { + active: true, + label: 'Jan 2024', + payload: tooltipPayload, + }), +})); + +// jsdom does not implement matchMedia, which the animation hook uses for +// reduced-motion detection +window.matchMedia = (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => false), +}); + +const { StackedBarGraph } = await import('./StackedBarGraph'); + +function makeEntry(name: string, value: number): TooltipEntry { + return { name, value, color: 'red', payload: { name, color: 'red' } }; +} + +// recharts hands the payload over in reverse order, and the tooltip reverses +// it back, so the payload here is written last-category-first +function renderTooltip(entries: TooltipEntry[], { compact = true } = {}) { + tooltipPayload = entries.slice(0).reverse(); + + render( + + + ({ + name: entry.name, + dataKey: entry.name, + color: entry.color, + })), + } as never + } + filters={[]} + groupBy="Category" + compact={compact} + viewLabels={false} + balanceTypeOp="totalDebts" + /> + + , + ); +} + +describe('StackedBarGraph tooltip', () => { + it('leaves out categories with no value for the interval', () => { + renderTooltip([ + makeEntry('Food', 1000), + makeEntry('Bills', 0), + makeEntry('Fun', 2000), + ]); + + expect(screen.getByText('Food')).toBeInTheDocument(); + expect(screen.getByText('Fun')).toBeInTheDocument(); + expect(screen.queryByText('Bills')).not.toBeInTheDocument(); + }); + + it('does not show the truncation indicator when every non-zero category is visible', () => { + renderTooltip([ + makeEntry('Food', 1000), + makeEntry('Bills', 0), + makeEntry('Fun', 2000), + makeEntry('Rent', 0), + makeEntry('Travel', 3000), + makeEntry('Gifts', 0), + ]); + + expect(document.body).not.toHaveTextContent('...'); + }); + + it('shows the truncation indicator when there are more non-zero categories than fit', () => { + renderTooltip([ + makeEntry('Food', 1000), + makeEntry('Bills', 2000), + makeEntry('Fun', 3000), + makeEntry('Rent', 4000), + makeEntry('Travel', 5000), + makeEntry('Gifts', 6000), + ]); + + expect(screen.queryByText('Gifts')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent('...'); + }); + + it('never truncates outside compact mode', () => { + renderTooltip( + [ + makeEntry('Food', 1000), + makeEntry('Bills', 2000), + makeEntry('Fun', 3000), + makeEntry('Rent', 4000), + makeEntry('Travel', 5000), + makeEntry('Gifts', 6000), + ], + { compact: false }, + ); + + expect(screen.getByText('Gifts')).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('...'); + }); +}); diff --git a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx index b17a5260f5..175190179f 100644 --- a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx @@ -91,16 +91,22 @@ const CustomTooltip = ({ const maxTooltipItems = 5; - const visibleItems = useMemo(() => { - const nonZero = items.filter(p => p.value !== 0); - if (!compact || nonZero.length <= maxTooltipItems) return nonZero; + const nonZeroItems = useMemo(() => { + return items.filter(p => p.value !== 0); + }, [items]); - const hoveredIndex = nonZero.findIndex(p => tooltip === p.name); + const visibleItems = useMemo(() => { + if (!compact || nonZeroItems.length <= maxTooltipItems) return nonZeroItems; + + const hoveredIndex = nonZeroItems.findIndex(p => tooltip === p.name); if (hoveredIndex >= maxTooltipItems) { - return [...nonZero.slice(0, maxTooltipItems - 1), nonZero[hoveredIndex]]; + return [ + ...nonZeroItems.slice(0, maxTooltipItems - 1), + nonZeroItems[hoveredIndex], + ]; } - return nonZero.slice(0, maxTooltipItems); - }, [compact, items, tooltip]); + return nonZeroItems.slice(0, maxTooltipItems); + }, [compact, nonZeroItems, tooltip]); if (active && items.length) { return ( @@ -139,7 +145,7 @@ const CustomTooltip = ({ /> ); })} - {compact && items.length > visibleItems.length && '...'} + {compact && nonZeroItems.length > visibleItems.length && '...'}