diff --git a/.changeset/services-tiles-refresh-pulse.md b/.changeset/services-tiles-refresh-pulse.md new file mode 100644 index 0000000000..c3ecf6c2a8 --- /dev/null +++ b/.changeset/services-tiles-refresh-pulse.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +fix: pulse the list-bar and histogram charts while they refresh + +The Services dashboard's list-bar and latency histogram charts kept their +previous data during a refresh but gave no sign that new data was loading, +and the histogram could briefly swap its chart for "Loading Chart Data...". +Both now keep their previous data on screen and pulse while the refetch runs, +like the other charts. diff --git a/packages/app/src/components/DBHistogramChart.tsx b/packages/app/src/components/DBHistogramChart.tsx index 6a4403cfe0..2244461c4a 100644 --- a/packages/app/src/components/DBHistogramChart.tsx +++ b/packages/app/src/components/DBHistogramChart.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useMemo, useState } from 'react'; +import cx from 'classnames'; import { omit } from 'lodash'; import { useHotkeys } from 'react-hotkeys-hook'; import { @@ -47,7 +48,13 @@ export function resolvePinnedBarIndex( return Number.isInteger(idx) && idx >= 0 ? idx : undefined; } -function HistogramChart({ graphResults }: { graphResults: any[] }) { +function HistogramChart({ + graphResults, + className, +}: { + graphResults: any[]; + className?: string; +}) { const data = useMemo(() => { return ( graphResults?.map((result: any) => { @@ -77,7 +84,12 @@ function HistogramChart({ graphResults }: { graphResults: any[] }) { }, [data]); return ( - + prev, queryKey: [queryKeyPrefix, queriedConfig], enabled, - }, - ); + }); const { data: mvOptimizationData } = useMVOptimizationExplanation(queriedConfig); @@ -272,18 +282,26 @@ export default function DBHistogramChart({ return ( - {isLoading ? ( + {isLoading && !data ? (
Loading Chart Data...
) : isError ? ( ) : data?.data.length === 0 ? ( -
+
No data found within time range.
) : ( - + )} ); diff --git a/packages/app/src/components/DBListBarChart.tsx b/packages/app/src/components/DBListBarChart.tsx index f008b346bd..ef463ff1df 100644 --- a/packages/app/src/components/DBListBarChart.tsx +++ b/packages/app/src/components/DBListBarChart.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import Link from 'next/link'; +import cx from 'classnames'; import { omit } from 'lodash'; import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; import type { FloatingPosition } from '@mantine/core'; @@ -202,14 +203,12 @@ export default function DBListBarChart({ errorVariant?: ChartErrorStateVariant; }) { const queriedConfig = omit(config, ['granularity']); - const { data, isLoading, isError, error } = useQueriedChartConfig( - queriedConfig, - { + const { data, isLoading, isPlaceholderData, isError, error } = + useQueriedChartConfig(queriedConfig, { placeholderData: (prev: any) => prev, queryKey: [queryKeyPrefix, queriedConfig], enabled, - }, - ); + }); const { data: mvOptimizationData } = useMVOptimizationExplanation(queriedConfig); @@ -285,18 +284,25 @@ export default function DBListBarChart({ ) : isError ? ( ) : data?.data.length === 0 ? ( -
+
No data found within time range.
) : ( - +
+ +
)} ); diff --git a/packages/app/src/components/__tests__/DBHistogramChart.test.tsx b/packages/app/src/components/__tests__/DBHistogramChart.test.tsx index 6cced53288..1b242fa12f 100644 --- a/packages/app/src/components/__tests__/DBHistogramChart.test.tsx +++ b/packages/app/src/components/__tests__/DBHistogramChart.test.tsx @@ -156,6 +156,85 @@ describe('DBHistogramChart', () => { // Verify DateRangeIndicator was not called expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled(); }); + + describe('refresh', () => { + const buckets = { data: [{ data: [[0, 10, 5]] }], meta: [] }; + const chart = () => + document.querySelector('.recharts-responsive-container'); + + it('keeps the previous histogram on screen and pulses it while a refresh loads', () => { + // The new range is still loading behind the previous buckets. + mockUseQueriedChartConfig.mockReturnValue({ + data: buckets, + isLoading: true, + isPlaceholderData: true, + isError: false, + }); + + renderWithMantine(); + + expect( + screen.queryByText('Loading Chart Data...'), + ).not.toBeInTheDocument(); + expect(chart()).toHaveClass('effect-pulse'); + }); + + it('does not pulse once fresh buckets load', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: buckets, + isLoading: false, + isPlaceholderData: false, + isError: false, + }); + + renderWithMantine(); + + expect(chart()).not.toHaveClass('effect-pulse'); + }); + + it('pulses the empty state while a refresh loads', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [], meta: [] }, + isLoading: false, + isPlaceholderData: true, + isError: false, + }); + + renderWithMantine(); + + expect(screen.getByText('No data found within time range.')).toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse a fresh empty result', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [], meta: [] }, + isLoading: false, + isPlaceholderData: false, + isError: false, + }); + + renderWithMantine(); + + expect( + screen.getByText('No data found within time range.'), + ).not.toHaveClass('effect-pulse'); + }); + + it('shows the loading state before the first buckets arrive', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + isLoading: true, + isPlaceholderData: false, + isError: false, + }); + + renderWithMantine(); + + expect(screen.getByText('Loading Chart Data...')).toBeInTheDocument(); + }); + }); }); describe('HISTOGRAM_BAR_COLOR', () => { diff --git a/packages/app/src/components/__tests__/DBListBarChart.test.tsx b/packages/app/src/components/__tests__/DBListBarChart.test.tsx index 6c6a30fa28..9fbb6c218f 100644 --- a/packages/app/src/components/__tests__/DBListBarChart.test.tsx +++ b/packages/app/src/components/__tests__/DBListBarChart.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { screen } from '@testing-library/react'; import DateRangeIndicator from '@/components/charts/DateRangeIndicator'; import DBListBarChart from '@/components/DBListBarChart'; @@ -169,4 +170,73 @@ describe('DBListBarChart', () => { // Verify DateRangeIndicator was not called expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled(); }); + + describe('refresh', () => { + const renderChart = () => + renderWithMantine( + , + ); + + it('pulses the previous bars while a refresh loads', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [{ group: 'checkout', value: 100 }] }, + isLoading: false, + isPlaceholderData: true, + isError: false, + }); + + renderChart(); + + expect( + screen.getByText('checkout').closest('.effect-pulse'), + ).toBeInTheDocument(); + }); + + it('does not pulse once fresh bars load', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [{ group: 'checkout', value: 100 }] }, + isLoading: false, + isPlaceholderData: false, + isError: false, + }); + + renderChart(); + + expect(screen.getByText('checkout').closest('.effect-pulse')).toBeNull(); + }); + + it('pulses the empty state while a refresh loads', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [] }, + isLoading: false, + isPlaceholderData: true, + isError: false, + }); + + renderChart(); + + expect(screen.getByText('No data found within time range.')).toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse a fresh empty result', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [] }, + isLoading: false, + isPlaceholderData: false, + isError: false, + }); + + renderChart(); + + expect( + screen.getByText('No data found within time range.'), + ).not.toHaveClass('effect-pulse'); + }); + }); });