Compare commits

..

10 Commits

Author SHA1 Message Date
Abhi Kumar
c775ef0e89 fix: updated the way we used to consume graph visibility state 2026-02-13 02:42:38 +05:30
Abhi Kumar
508c8a3dbc fix: added tests failure handing + moved from map based approach to array 2026-02-13 02:21:47 +05:30
Abhi Kumar
9e057b3acf chore: fixed plotcontext test 2026-02-13 00:28:20 +05:30
Abhi kumar
71df49ca6c Merge branch 'main' into test/uplotv2-utils 2026-02-12 22:46:07 +05:30
Abhi Kumar
a4501a54d3 chore: pr review comments 2026-02-12 22:44:02 +05:30
Abhi kumar
a49d7e1662 chore: resetting spangaps to old default state in the new timeseries chart + added thresholds in scale computation (#10287)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: moved spangaps to old default state in the new timeseries chart

* chore: added thresholds in scale builder
2026-02-12 22:22:41 +05:30
Abhi kumar
16ad7191ff Merge branch 'main' into test/uplotv2-utils 2026-02-12 21:21:48 +05:30
Abhi kumar
44e9ce7995 Merge branch 'main' into test/uplotv2-utils 2026-02-12 20:58:04 +05:30
Abhi Kumar
c266fa0087 fix: added fix for legend test 2026-02-12 17:08:25 +05:30
Abhi Kumar
54655c6ab2 test: added tests for utils + components 2026-02-12 16:53:06 +05:30
22 changed files with 1454 additions and 662 deletions

View File

@@ -1,117 +0,0 @@
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import BarChartTooltip from 'lib/uPlotV2/components/Tooltip/BarChartTooltip';
import {
BarTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { BarChartProps } from '../types';
import { stack } from './stackUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle) */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
export default function BarChart(props: BarChartProps): JSX.Element {
const { children, isStackedBarChart, config, data, ...rest } = props;
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
return data as uPlot.AlignedData;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stack(data as uPlot.AlignedData, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
const hasValidData =
unstacked && plot.data && unstacked[0]?.length === plot.data[0]?.length;
if (!hasValidData || isUpdatingChartRef.current) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stack(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
return undefined;
}
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingChartRef.current) {
applyStackingToChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
if (has(opts, 'focus')) {
return;
}
applyStackingToChart(plot);
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}, [isStackedBarChart, config, applyStackingToChart]);
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
const tooltipProps: BarTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
};
return <BarChartTooltip {...tooltipProps} />;
},
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
);
return (
<ChartWrapper
{...rest}
config={config}
data={chartData}
renderTooltip={renderTooltip}
>
{children}
</ChartWrapper>
);
}

View File

@@ -1,57 +0,0 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When omit(i) returns true for a series index, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const d0Len = data[0].length;
const n = data.length - 1;
const data2: (number | null)[][] = Array(n);
const accum = Array(d0Len).fill(0) as number[];
// Accumulate from last series upward: last = raw, first = total
for (let i = n; i >= 1; i--) {
const seriesData = data[i] as (number | null)[];
if (omit(i)) {
data2[i - 1] = seriesData;
} else {
data2[i - 1] = seriesData.map((v, idx) => {
const val = v == null ? 0 : Number(v);
return (accum[idx] += val);
});
}
}
const bands: uPlot.Band[] = [];
// Bands: [upper, lower] - fill between consecutive visible series
for (let i = 1; i < data.length; i++) {
if (!omit(i)) {
const nextIdx = data.findIndex((_, j) => j > i && !omit(j));
if (nextIdx >= 1) {
bands.push({ series: [i, nextIdx] });
}
}
}
return {
data: [data[0], ...data2] as AlignedData,
bands,
};
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let i = 1; i < seriesCount; i++) {
bands.push({ series: [i, i + 1] });
}
return bands;
}

View File

@@ -1,154 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
panelMode,
queryResponse,
widget,
onDragSelect,
isFullViewMode,
onToggleModelHandler,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useEffect(() => {
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [queryResponse]);
const {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
} = usePanelContextMenu({
widget,
queryResponse,
});
const config = useMemo(() => {
return prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery: widget.query,
onClick: clickHandlerWithContextMenu,
onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
timezone,
panelMode,
minTimeScale: minTimeScale,
maxTimeScale: maxTimeScale,
});
}, [
widget,
isDarkMode,
queryResponse?.data?.payload,
clickHandlerWithContextMenu,
onDragSelect,
minTimeScale,
maxTimeScale,
timezone,
panelMode,
]);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {
if (!isFullViewMode) {
return null;
}
return (
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={widget.yAxisUnit}
onCancel={onToggleModelHandler}
/>
);
}, [
isFullViewMode,
config,
chartData,
widget.yAxisUnit,
onToggleModelHandler,
]);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
plotRef={(plot: uPlot | null): void => {
uPlotRef.current = plot;
}}
onDestroy={(): void => {
uPlotRef.current = null;
}}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
</BarChart>
)}
</div>
);
}
export default BarPanel;

View File

@@ -1,108 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/BarChart/stackUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { AlignedData } from 'uplot';
import { PanelMode } from '../types';
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export function prepareBarPanelData(
apiResponse: MetricRangePayloadProps,
): AlignedData {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
}
export function prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
}: {
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;
panelMode: PanelMode;
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder {
const builder = buildBaseConfig({
widget,
isDarkMode,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
builder.setCursor({
focus: {
prox: 1e3,
},
});
if (widget.stackedBarChart) {
const seriesCount = (apiResponse?.data?.result?.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = currentQuery
? getLegend(series, currentQuery, baseLabelName)
: baseLabelName;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.BAR,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
});
});
return builder;
}

View File

@@ -83,7 +83,7 @@ export const prepareUPlotConfig = ({
drawStyle: DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,

View File

@@ -14,6 +14,11 @@ export interface GraphVisibilityState {
dataIndex: SeriesVisibilityItem[];
}
export interface SeriesVisibilityState {
lables: string[];
visibility: boolean[];
}
/**
* Context in which a panel is rendered. Used to vary behavior (e.g. persistence,
* interactions) per context.

View File

@@ -0,0 +1,271 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import type { GraphVisibilityState } from '../../types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../legendVisibilityUtils';
describe('legendVisibilityUtils', () => {
const storageKey = LOCALSTORAGE.GRAPH_VISIBILITY_STATES;
beforeEach(() => {
localStorage.clear();
jest.spyOn(window.localStorage.__proto__, 'getItem');
jest.spyOn(window.localStorage.__proto__, 'setItem');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getStoredSeriesVisibility', () => {
it('returns null when there is no stored visibility state', () => {
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
expect(localStorage.getItem).toHaveBeenCalledWith(storageKey);
});
it('returns null when widget has no stored dataIndex', () => {
const stored: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
it('returns visibility array by index when widget state exists', () => {
const stored: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
],
},
{
name: 'widget-2',
dataIndex: [{ label: 'Errors', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).not.toBeNull();
expect(result).toEqual({
lables: ['CPU', 'Memory'],
visibility: [true, false],
});
});
it('returns visibility by index including duplicate labels', () => {
const stored: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [
{ label: 'CPU', show: true },
{ label: 'CPU', show: false },
{ label: 'Memory', show: false },
],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).not.toBeNull();
expect(result).toEqual({
lables: ['CPU', 'CPU', 'Memory'],
visibility: [true, false, false],
});
});
it('returns null on malformed JSON in localStorage', () => {
localStorage.setItem(storageKey, '{invalid-json');
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
it('returns null when widget id is not found', () => {
const stored: GraphVisibilityState[] = [
{
name: 'another-widget',
dataIndex: [{ label: 'CPU', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
});
describe('updateSeriesVisibilityToLocalStorage', () => {
it('creates new visibility state when none exists', () => {
const seriesVisibility = [
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
];
updateSeriesVisibilityToLocalStorage('widget-1', seriesVisibility);
const stored = getStoredSeriesVisibility('widget-1');
expect(stored).not.toBeNull();
expect(stored).toEqual({
lables: ['CPU', 'Memory'],
visibility: [true, false],
});
});
it('adds a new widget entry when other widgets already exist', () => {
const existing: GraphVisibilityState[] = [
{
name: 'widget-existing',
dataIndex: [{ label: 'Errors', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(existing));
const newVisibility = [{ label: 'CPU', show: false }];
updateSeriesVisibilityToLocalStorage('widget-new', newVisibility);
const stored = getStoredSeriesVisibility('widget-new');
expect(stored).not.toBeNull();
expect(stored).toEqual({ lables: ['CPU'], visibility: [false] });
});
it('updates existing widget visibility when entry already exists', () => {
const initialVisibility: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
],
},
];
localStorage.setItem(storageKey, JSON.stringify(initialVisibility));
const updatedVisibility = [
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
];
updateSeriesVisibilityToLocalStorage('widget-1', updatedVisibility);
const stored = getStoredSeriesVisibility('widget-1');
expect(stored).not.toBeNull();
expect(stored).toEqual({
lables: ['CPU', 'Memory'],
visibility: [false, true],
});
});
it('silently handles malformed existing JSON without throwing', () => {
localStorage.setItem(storageKey, '{invalid-json');
expect(() =>
updateSeriesVisibilityToLocalStorage('widget-1', [
{ label: 'CPU', show: true },
]),
).not.toThrow();
});
it('when existing JSON is malformed, overwrites with valid data for the widget', () => {
localStorage.setItem(storageKey, '{invalid-json');
updateSeriesVisibilityToLocalStorage('widget-1', [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
]);
const stored = getStoredSeriesVisibility('widget-1');
expect(stored).not.toBeNull();
expect(stored).toEqual({
lables: ['x-axis', 'CPU'],
visibility: [true, false],
});
const expected = [
{
name: 'widget-1',
dataIndex: [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
],
},
];
expect(localStorage.setItem).toHaveBeenCalledWith(
storageKey,
JSON.stringify(expected),
);
});
it('preserves other widgets when updating one widget', () => {
const existing: GraphVisibilityState[] = [
{ name: 'widget-a', dataIndex: [{ label: 'A', show: true }] },
{ name: 'widget-b', dataIndex: [{ label: 'B', show: false }] },
];
localStorage.setItem(storageKey, JSON.stringify(existing));
updateSeriesVisibilityToLocalStorage('widget-b', [
{ label: 'B', show: true },
]);
expect(getStoredSeriesVisibility('widget-a')).toEqual({
lables: ['A'],
visibility: [true],
});
expect(getStoredSeriesVisibility('widget-b')).toEqual({
lables: ['B'],
visibility: [true],
});
});
it('calls setItem with storage key and stringified visibility states', () => {
updateSeriesVisibilityToLocalStorage('widget-1', [
{ label: 'CPU', show: true },
]);
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
expect(localStorage.setItem).toHaveBeenCalledWith(
storageKey,
expect.any(String),
);
const [_, value] = (localStorage.setItem as jest.Mock).mock.calls[0];
expect((): void => JSON.parse(value)).not.toThrow();
expect(JSON.parse(value)).toEqual([
{ name: 'widget-1', dataIndex: [{ label: 'CPU', show: true }] },
]);
});
it('stores empty dataIndex when seriesVisibility is empty', () => {
updateSeriesVisibilityToLocalStorage('widget-1', []);
const raw = localStorage.getItem(storageKey);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw ?? '[]');
expect(parsed).toEqual([{ name: 'widget-1', dataIndex: [] }]);
expect(getStoredSeriesVisibility('widget-1')).toBeNull();
});
});
});

View File

@@ -88,7 +88,7 @@ export function buildBaseConfig({
max: undefined,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
// thresholds,
thresholds: thresholdOptions,
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
? DistributionType.Logarithmic

View File

@@ -1,15 +1,20 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
import {
GraphVisibilityState,
SeriesVisibilityItem,
SeriesVisibilityState,
} from '../types';
/**
* Retrieves the visibility map for a specific widget from localStorage
* Retrieves the stored series visibility for a specific widget from localStorage by index.
* Index 0 is the x-axis (time); indices 1, 2, ... are data series (same order as uPlot plot.series).
* @param widgetId - The unique identifier of the widget
* @returns A Map of series labels to their visibility state, or null if not found
* @returns visibility[i] = show state for series at index i, or null if not found
*/
export function getStoredSeriesVisibility(
widgetId: string,
): Map<string, boolean> | null {
): SeriesVisibilityState | null {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
@@ -24,8 +29,15 @@ export function getStoredSeriesVisibility(
return null;
}
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
} catch {
return {
lables: widgetState.dataIndex.map((item) => item.label),
visibility: widgetState.dataIndex.map((item) => item.show),
};
} catch (error) {
if (error instanceof SyntaxError) {
// If the stored data is malformed, remove it
localStorage.removeItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
}
// Silently handle parsing errors - fall back to default visibility
return null;
}
@@ -35,40 +47,31 @@ export function updateSeriesVisibilityToLocalStorage(
widgetId: string,
seriesVisibility: SeriesVisibilityItem[],
): void {
let visibilityStates: GraphVisibilityState[] = [];
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
let visibilityStates: GraphVisibilityState[];
if (!storedData) {
visibilityStates = [
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
visibilityStates = JSON.parse(storedData);
visibilityStates = JSON.parse(storedData || '[]');
} catch (error) {
if (error instanceof SyntaxError) {
visibilityStates = [];
}
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState) {
visibilityStates = [
...visibilityStates,
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
widgetState.dataIndex = seriesVisibility;
}
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(visibilityStates),
);
} catch {
// Silently handle parsing errors - fall back to default visibility
}
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (widgetState) {
widgetState.dataIndex = seriesVisibility;
} else {
visibilityStates = [
...visibilityStates,
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
}
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(visibilityStates),
);
}

View File

@@ -5,8 +5,8 @@ import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
@@ -106,6 +106,7 @@ export default function Legend({
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>

View File

@@ -1,31 +0,0 @@
import { useMemo } from 'react';
import { BarTooltipProps, TooltipContentItem } from '../types';
import Tooltip from './Tooltip';
import { buildTooltipContent } from './utils';
export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
isStackedBarChart: props.isStackedBarChart,
}),
[
props.uPlotInstance,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,
props.decimalPrecision,
props.isStackedBarChart,
],
);
return <Tooltip {...props} content={content} />;
}

View File

@@ -25,28 +25,16 @@ export function getTooltipBaseValue({
index,
dataIndex,
isStackedBarChart,
series,
}: {
data: AlignedData;
index: number;
dataIndex: number;
isStackedBarChart?: boolean;
series?: Series[];
}): number | null {
let baseValue = data[index][dataIndex] ?? null;
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
// When series are hidden, we must use the next *visible* series, not index+1,
// since hidden series keep raw values and would produce negative/wrong results.
if (isStackedBarChart && baseValue !== null && series) {
let nextVisibleIdx = -1;
for (let j = index + 1; j < series.length; j++) {
if (series[j]?.show) {
nextVisibleIdx = j;
break;
}
}
if (nextVisibleIdx >= 1) {
const nextValue = data[nextVisibleIdx][dataIndex] ?? 0;
if (isStackedBarChart && index + 1 < data.length && baseValue !== null) {
const nextValue = data[index + 1][dataIndex] ?? null;
if (nextValue !== null) {
baseValue = baseValue - nextValue;
}
}
@@ -92,7 +80,6 @@ export function buildTooltipContent({
index,
dataIndex,
isStackedBarChart,
series,
});
const isActive = index === activeSeriesIndex;

View File

@@ -0,0 +1,213 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import Legend from '../Legend/Legend';
import { LegendPosition } from '../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('Legend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<Legend
position={position}
// config is not used directly in the component, it's consumed by the mocked hook
config={{} as any}
/>,
);
describe('layout and position', () => {
it('renders search input when legend position is RIGHT', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('does not render search input when legend position is BOTTOM (default)', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
});
it('renders the marker with the correct border color', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
'border-color': '#ff0000',
});
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
await user.hover(container);
await user.unhover(container);
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,3 +1,4 @@
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
@@ -235,9 +236,9 @@ export class UPlotConfigBuilder extends ConfigBuilder<
}
/**
* Returns stored series visibility map from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
* Returns stored series visibility by index from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
*/
private getStoredVisibilityMap(): Map<string, boolean> | null {
private getStoredVisibility(): SeriesVisibilityState | null {
if (
this.widgetId &&
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
@@ -251,22 +252,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
* Get legend items with visibility state restored from localStorage if available
*/
getLegendItems(): Record<number, LegendItem> {
const visibilityMap = this.getStoredVisibilityMap();
const isAnySeriesHidden = !!(
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
const seriesVisibilityState = this.getStoredVisibility();
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
(show) => !show,
);
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
const seriesConfig = s.getConfig();
const label = seriesConfig.label ?? '';
const seriesIndex = index + 1; // +1 because the first series is the timestamp
const show = resolveSeriesVisibility(
label,
seriesConfig.show,
visibilityMap,
// +1 because uPlot series 0 is x-axis/time; data series are at 1, 2, ... (also matches stored visibility[0]=time, visibility[1]=first data, ...)
const seriesIndex = index + 1;
const show = resolveSeriesVisibility({
seriesIndex,
seriesShow: seriesConfig.show,
seriesLabel: label,
seriesVisibilityState,
isAnySeriesHidden,
);
});
acc[seriesIndex] = {
seriesIndex,
@@ -294,22 +296,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
...DEFAULT_PLOT_CONFIG,
};
const visibilityMap = this.getStoredVisibilityMap();
const isAnySeriesHidden = !!(
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
const seriesVisibilityState = this.getStoredVisibility();
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
(show) => !show,
);
config.series = [
{ value: (): string => '' }, // Base series for timestamp
...this.series.map((s) => {
...this.series.map((s, index) => {
const series = s.getConfig();
const label = series.label ?? '';
const visible = resolveSeriesVisibility(
label,
series.show,
visibilityMap,
// Stored visibility[0] is x-axis/time; data series start at visibility[1]
const visible = resolveSeriesVisibility({
seriesIndex: index + 1,
seriesShow: series.show,
seriesLabel: series.label ?? '',
seriesVisibilityState,
isAnySeriesHidden,
);
});
return {
...series,
show: visible,

View File

@@ -1,10 +1,8 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import uPlot, { Series } from 'uplot';
import {
BarAlignment,
ConfigBuilder,
DrawStyle,
LineInterpolation,
@@ -46,10 +44,15 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
private buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
}: {
lineColor: string;
lineWidth?: number;
lineStyle?: LineStyle;
lineCap?: Series.Cap;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap } = this.props;
const lineConfig: Partial<Series> = {
stroke: lineColor,
width: lineWidth ?? 2,
@@ -62,26 +65,21 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
if (lineCap) {
lineConfig.cap = lineCap;
}
if (this.props.panelType === PANEL_TYPES.BAR) {
lineConfig.fill = lineColor;
}
return lineConfig;
}
/**
* Build path configuration
*/
private buildPathConfig(): Partial<Series> {
const {
pathBuilder,
drawStyle,
lineInterpolation,
barAlignment,
barMaxWidth,
barWidthFactor,
} = this.props;
private buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
}: {
pathBuilder?: Series.PathBuilder | null;
drawStyle: DrawStyle;
lineInterpolation?: LineInterpolation;
}): Partial<Series> {
if (pathBuilder) {
return { paths: pathBuilder };
}
@@ -98,13 +96,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
idx0: number,
idx1: number,
): Series.Paths | null => {
const pathsBuilder = getPathBuilder({
drawStyle,
lineInterpolation,
barAlignment,
barMaxWidth,
barWidthFactor,
});
const pathsBuilder = getPathBuilder(drawStyle, lineInterpolation);
return pathsBuilder(self, seriesIdx, idx0, idx1);
},
@@ -119,17 +111,21 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
*/
private buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
}: {
lineColor: string;
lineWidth?: number;
pointSize?: number;
pointsBuilder: Series.Points.Show | null;
pointsFilter: Series.Points.Filter | null;
drawStyle: DrawStyle;
showPoints?: VisibilityMode;
}): Partial<Series.Points> {
const {
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
} = this.props;
const pointsConfig: Partial<Series.Points> = {
stroke: lineColor,
fill: lineColor,
@@ -166,16 +162,44 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
}
getConfig(): Series {
const { scaleKey, label, spanGaps, show = true } = this.props;
const {
drawStyle,
pathBuilder,
pointsBuilder,
pointsFilter,
lineInterpolation,
lineWidth,
lineStyle,
lineCap,
showPoints,
pointSize,
scaleKey,
label,
spanGaps,
show = true,
} = this.props;
const lineColor = this.getLineColor();
const lineConfig = this.buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
});
const pathConfig = this.buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
});
const pathConfig = this.buildPathConfig();
const pointsConfig = this.buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder: pointsBuilder ?? null,
pointsFilter: pointsFilter ?? null,
drawStyle,
showPoints,
});
return {
@@ -203,37 +227,15 @@ interface PathBuilders {
/**
* Get path builder based on draw style and interpolation
*/
function getPathBuilder({
drawStyle,
lineInterpolation,
barAlignment = BarAlignment.Center,
barWidthFactor = 0.6,
barMaxWidth = 200,
}: {
drawStyle: DrawStyle;
lineInterpolation?: LineInterpolation;
barAlignment?: BarAlignment;
barMaxWidth?: number;
barWidthFactor?: number;
}): Series.PathBuilder {
const pathBuilders = uPlot.paths;
function getPathBuilder(
style: DrawStyle,
lineInterpolation?: LineInterpolation,
): Series.PathBuilder {
if (!builders) {
throw new Error('Required uPlot path builders are not available');
}
if (drawStyle === DrawStyle.Bar) {
const barsCfgKey = `bars|${barAlignment}|${barWidthFactor}|${barMaxWidth}`;
if (!builders[barsCfgKey] && pathBuilders.bars) {
builders[barsCfgKey] = pathBuilders.bars({
size: [barWidthFactor, barMaxWidth],
align: barAlignment,
});
}
return builders[barsCfgKey];
}
if (drawStyle === DrawStyle.Line) {
if (style === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
return builders.stepBefore;
}

View File

@@ -186,14 +186,11 @@ describe('UPlotConfigBuilder', () => {
});
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
const visibilityMap = new Map<string, boolean>([
['Requests', true],
['Errors', false],
]);
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue(
visibilityMap,
);
// Index 0 = x-axis/time; indices 1,2 = data series (Requests, Errors). resolveSeriesVisibility matches by seriesIndex + seriesLabel.
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue({
lables: ['x-axis', 'Requests', 'Errors'],
visibility: [true, true, false],
});
const builder = new UPlotConfigBuilder({
widgetId: 'widget-1',

View File

@@ -126,45 +126,7 @@ export enum VisibilityMode {
Never = 'never',
}
/**
* Props for configuring lines
*/
export interface LineConfig {
lineColor?: string;
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
lineCap?: Series.Cap;
}
/**
* Alignment of bars
*/
export enum BarAlignment {
After = 1,
Before = -1,
Center = 0,
}
/**
* Props for configuring bars
*/
export interface BarConfig {
barAlignment?: BarAlignment;
barMaxWidth?: number;
barWidthFactor?: number;
}
/**
* Props for configuring points
*/
export interface PointsConfig {
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
}
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
export interface SeriesProps {
scaleKey: string;
label?: string;
panelType: PANEL_TYPES;
@@ -175,7 +137,20 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
isDarkMode?: boolean;
// Line config
lineColor?: string;
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
lineCap?: Series.Cap;
// Points config
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
}
export interface LegendItem {

View File

@@ -0,0 +1,395 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import {
PlotContextProvider,
usePlotContext,
} from 'lib/uPlotV2/context/PlotContext';
import type uPlot from 'uplot';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
updateSeriesVisibilityToLocalStorage: jest.fn(),
}),
);
const mockUpdateSeriesVisibilityToLocalStorage = updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
interface MockSeries extends Partial<uPlot.Series> {
label?: string;
show?: boolean;
}
const createMockPlot = (series: MockSeries[] = []): uPlot =>
(({
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
} as unknown) as uPlot);
interface TestComponentProps {
plot?: uPlot;
widgetId?: string;
shouldSaveSelectionPreference?: boolean;
}
const TestComponent = ({
plot,
widgetId,
shouldSaveSelectionPreference,
}: TestComponentProps): JSX.Element => {
const {
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onFocusSeries,
} = usePlotContext();
const handleInit = (): void => {
if (
!plot ||
!widgetId ||
typeof shouldSaveSelectionPreference !== 'boolean'
) {
return;
}
setPlotContextInitialState({
uPlotInstance: plot,
widgetId,
shouldSaveSelectionPreference,
});
};
return (
<div>
<button type="button" data-testid="init" onClick={handleInit}>
Init
</button>
<button
type="button"
data-testid="sync-visibility"
onClick={(): void => syncSeriesVisibilityToLocalStorage()}
>
Sync visibility
</button>
<button
type="button"
data-testid="toggle-visibility"
onClick={(): void => onToggleSeriesVisibility(1)}
>
Toggle visibility
</button>
<button
type="button"
data-testid="toggle-on-off-1"
onClick={(): void => onToggleSeriesOnOff(1)}
>
Toggle on/off 1
</button>
<button
type="button"
data-testid="toggle-on-off-5"
onClick={(): void => onToggleSeriesOnOff(5)}
>
Toggle on/off 5
</button>
<button
type="button"
data-testid="focus-series"
onClick={(): void => onFocusSeries(1)}
>
Focus series
</button>
</div>
);
};
describe('PlotContext', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when usePlotContext is used outside provider', () => {
const Consumer = (): JSX.Element => {
// eslint-disable-next-line react-hooks/rules-of-hooks
usePlotContext();
return <div />;
};
expect(() => render(<Consumer />)).toThrow(
'Should be used inside the context',
);
});
it('syncSeriesVisibilityToLocalStorage does nothing without plot or widgetId', async () => {
const user = userEvent.setup();
render(
<PlotContextProvider>
<TestComponent />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('sync-visibility'));
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('syncSeriesVisibilityToLocalStorage serializes series visibility to localStorage helper', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-123"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('sync-visibility'));
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-123',
[
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
],
);
});
describe('onToggleSeriesVisibility', () => {
it('does nothing when plot instance is not set', async () => {
const user = userEvent.setup();
render(
<PlotContextProvider>
<TestComponent />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('toggle-visibility'));
// No errors and no calls to localStorage helper
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('highlights a single series and saves visibility when preferences are enabled', async () => {
const user = userEvent.setup();
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-visibility"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-visibility'));
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
// index 0 is skipped, so we expect calls for 1 and 2
expect(setSeries).toEqual([
[1, { show: true }],
[2, { show: false }],
]);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-visibility',
[
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
],
);
});
it('resets visibility for all series when toggling the same index again', async () => {
const user = userEvent.setup();
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-reset"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-visibility'));
(plot.setSeries as jest.Mock).mockClear();
await user.click(screen.getByTestId('toggle-visibility'));
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
// After reset, all non-zero series should be shown
expect(setSeries).toEqual([
[1, { show: true }],
[2, { show: true }],
]);
});
});
describe('onToggleSeriesOnOff', () => {
it('does nothing when plot instance is not set', async () => {
const user = userEvent.setup();
render(
<PlotContextProvider>
<TestComponent />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('toggles series show flag and saves visibility when preferences are enabled', async () => {
const user = userEvent.setup();
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
];
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-toggle"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-toggle',
expect.any(Array),
);
});
it('does not toggle when target series does not exist', async () => {
const user = userEvent.setup();
const series: MockSeries[] = [{ label: 'x-axis', show: true }];
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-missing-series"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-5'));
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('does not persist visibility when preferences flag is disabled', async () => {
const user = userEvent.setup();
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
];
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-no-persist"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('does nothing when plot instance is not set', async () => {
const user = userEvent.setup();
render(
<PlotContextProvider>
<TestComponent />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('focus-series'));
});
it('sets focus on the given series index', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
]);
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-focus"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('focus-series'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
});

View File

@@ -0,0 +1,201 @@
import { renderHook } from '@testing-library/react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
jest.mock('lib/uPlotV2/context/PlotContext');
const mockUsePlotContext = usePlotContext as jest.MockedFunction<
typeof usePlotContext
>;
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback): number => {
cb(0);
return 1;
});
cancelAnimationFrameSpy = jest
.spyOn(global, 'cancelAnimationFrame')
.mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onFocusSeriesPlot = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onFocusSeries: onFocusSeriesPlot,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
cancelAnimationFrameSpy.mockClear();
});
const createMouseEvent = (options: {
legendItemId?: number;
isMarker?: boolean;
}): any => {
const { legendItemId, isMarker = false } = options;
return {
target: {
dataset: {
...(isMarker ? { isLegendMarker: 'true' } : {}),
},
closest: jest.fn(() =>
legendItemId !== undefined
? { dataset: { legendItemId: String(legendItemId) } }
: null,
),
},
};
};
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(
createMouseEvent({ legendItemId: 0, isMarker: true }),
);
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('does nothing when click target is not inside a legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({}));
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
});
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
});
});
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 0,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 1,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseLeave();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,192 @@
import { act, cleanup, renderHook } from '@testing-library/react';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
describe('useLegendsSync', () => {
let requestAnimationFrameSpy: jest.SpyInstance<
number,
[callback: FrameRequestCallback]
>;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
requestAnimationFrameSpy = jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback): number => {
cb(0);
return 1;
});
cancelAnimationFrameSpy = jest
.spyOn(global, 'cancelAnimationFrame')
.mockImplementation(() => {});
});
afterEach(() => {
jest.clearAllMocks();
cleanup();
});
afterAll(() => {
jest.restoreAllMocks();
});
const createMockConfig = (
legendItems: Record<number, LegendItem>,
): {
config: UPlotConfigBuilder;
invokeSetSeries: (
seriesIndex: number | null,
opts: { show?: boolean; focus?: boolean },
fireHook?: boolean,
) => void;
} => {
let setSeriesHandler:
| ((u: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const config = ({
getLegendItems: jest.fn(() => legendItems),
addHook: jest.fn(
(
hookName: string,
handler: (
u: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void,
) => {
if (hookName === 'setSeries') {
setSeriesHandler = handler;
}
return (): void => {
setSeriesHandler = null;
};
},
),
} as unknown) as UPlotConfigBuilder;
const invokeSetSeries = (
seriesIndex: number | null,
opts: { show?: boolean; focus?: boolean },
): void => {
if (setSeriesHandler) {
setSeriesHandler({} as uPlot, seriesIndex, { ...opts });
}
};
return { config, invokeSetSeries };
};
it('initializes legend items from config', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
2: { seriesIndex: 2, label: 'Memory', show: false, color: '#0f0' },
};
const { config } = createMockConfig(initialItems);
const { result } = renderHook(() => useLegendsSync({ config }));
expect(config.getLegendItems).toHaveBeenCalledTimes(1);
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
expect(result.current.legendItemsMap).toEqual(initialItems);
});
it('updates focusedSeriesIndex when a series gains focus via setSeries by default', async () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
const { result } = renderHook(() => useLegendsSync({ config }));
expect(result.current.focusedSeriesIndex).toBeNull();
await act(async () => {
invokeSetSeries(1, { focus: true });
});
expect(result.current.focusedSeriesIndex).toBe(1);
});
it('does not update focusedSeriesIndex when subscribeToFocusChange is false', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
const { result } = renderHook(() =>
useLegendsSync({ config, subscribeToFocusChange: false }),
);
invokeSetSeries(1, { focus: true });
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('updates legendItemsMap visibility when show changes for a series', async () => {
const initialItems: Record<number, LegendItem> = {
0: { seriesIndex: 0, label: 'x-axis', show: true, color: '#000' },
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
const { result } = renderHook(() => useLegendsSync({ config }));
// Toggle visibility of series 1
await act(async () => {
invokeSetSeries(1, { show: false });
});
expect(result.current.legendItemsMap[1].show).toBe(false);
});
it('ignores visibility updates for unknown legend items or unchanged show values', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
const { result } = renderHook(() => useLegendsSync({ config }));
const before = result.current.legendItemsMap;
// Unknown series index
invokeSetSeries(5, { show: false });
// Unchanged visibility for existing item
invokeSetSeries(1, { show: true });
const after = result.current.legendItemsMap;
expect(after).toEqual(before);
});
it('cancels pending visibility RAF on unmount', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
// Override RAF to not immediately invoke callback so we can assert cancellation
requestAnimationFrameSpy.mockImplementationOnce(() => 42);
const { unmount } = renderHook(() => useLegendsSync({ config }));
invokeSetSeries(1, { show: false });
unmount();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
});
});

View File

@@ -1,11 +1,25 @@
export function resolveSeriesVisibility(
label: string,
seriesShow: boolean | undefined | null,
visibilityMap: Map<string, boolean> | null,
isAnySeriesHidden: boolean,
): boolean {
if (isAnySeriesHidden) {
return visibilityMap?.get(label) ?? false;
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
export function resolveSeriesVisibility({
seriesIndex,
seriesShow,
seriesLabel,
seriesVisibilityState,
isAnySeriesHidden,
}: {
seriesIndex: number;
seriesShow: boolean | undefined | null;
seriesLabel: string;
seriesVisibilityState: SeriesVisibilityState | null;
isAnySeriesHidden: boolean;
}): boolean {
if (
isAnySeriesHidden &&
seriesVisibilityState?.visibility &&
seriesVisibilityState.lables.length > seriesIndex &&
seriesVisibilityState.lables[seriesIndex] === seriesLabel
) {
return seriesVisibilityState.visibility[seriesIndex] ?? false;
}
return seriesShow ?? true;
}