Compare commits

...

1 Commits

Author SHA1 Message Date
Abhi Kumar
825a07c9fe feat: user dashboard preference 2026-05-01 16:43:40 +05:30
11 changed files with 626 additions and 4 deletions

View File

@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -1,12 +1,43 @@
.overview-content {
display: flex;
flex-direction: column;
gap: 16px;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
&.cross-panel-sync {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
.cross-panel-sync__info {
display: flex;
flex-direction: column;
gap: 4px;
.cross-panel-sync__title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.cross-panel-sync__description {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
}
}
.name-icon-input {
display: flex;
.dashboard-image-input {

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
@@ -19,6 +21,10 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const selectedData = dashboardData?.data;
const {
@@ -159,6 +165,28 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
</Space>
</Col>
<Col className="overview-settings cross-panel-sync">
<div className="cross-panel-sync__info">
<Typography.Text className="cross-panel-sync__title">
Cross-Panel Sync
</Typography.Text>
<Typography.Text className="cross-panel-sync__description">
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</Col>
{numberOfUnsavedChanges > 0 && (
<div className="overview-settings-footer">
<div className="unsaved">

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -15,6 +17,8 @@ import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import get from 'lodash/get';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { PanelMode } from '../types';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -34,6 +38,9 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -75,6 +82,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -118,10 +130,18 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const cursorSyncMode = useMemo(() => {
if (panelMode !== PanelMode.DASHBOARD_VIEW) {
return DashboardCursorSync.None;
}
return syncMode;
}, [syncMode, panelMode]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={cursorSyncMode}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -138,6 +158,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={cursorSyncMode}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -3,10 +3,12 @@ import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSe
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -15,6 +17,8 @@ import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import { PanelMode } from '../types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -33,6 +37,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -81,6 +88,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -109,10 +121,18 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const cursorSyncMode = useMemo(() => {
if (panelMode !== PanelMode.DASHBOARD_VIEW) {
return DashboardCursorSync.None;
}
return syncMode;
}, [syncMode, panelMode]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={cursorSyncMode}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -125,6 +145,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
groupBy={groupBy}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={cursorSyncMode}
layoutChildren={layoutChildren}
>
<ContextMenu

View File

@@ -0,0 +1,58 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
describe('useDashboardCursorSyncMode', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults to Crosshair when no value is stored', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('reads the stored cursor sync mode for the dashboard', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
}),
);
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('persists the cursor sync mode under the cursorSyncMode key', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('returns the default when dashboardId is undefined and the setter is a no-op', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode(undefined));
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
});

View File

@@ -0,0 +1,185 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from '../useDashboardPreference';
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
const seedStore = (store: Record<string, unknown>): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
};
const readStore = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
};
describe('useDashboardPreference', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns the default value when no preference is stored', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the default value when dashboardId is undefined', () => {
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the stored value for the given dashboardId', () => {
seedStore({
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('persists the new value to localStorage when the setter is called', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(readStore()).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('does not write to localStorage when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('keeps multiple hook instances in sync after a write', () => {
const { result: writer } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: reader } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
writer.current[1](DashboardCursorSync.Tooltip);
});
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('isolates preferences across different dashboardIds', () => {
const { result: dashOne } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: dashTwo } = renderHook(() =>
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
dashOne.current[1](DashboardCursorSync.None);
});
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
});
it('does not overwrite preferences for other dashboards when writing', () => {
seedStore({
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(readStore()).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
});
});
it('returns the default value when localStorage contains malformed JSON', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
localStorage.setItem(STORAGE_KEY, '{not-json');
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('returns the default value when the stored payload is not an object', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('a-string'));
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('reacts to a native storage event from another tab', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
act(() => {
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY }));
});
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('ignores storage events for unrelated keys', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
window.dispatchEvent(new StorageEvent('storage', { key: 'SOME_OTHER_KEY' }));
});
// No notify => snapshot unchanged for the existing subscriber.
expect(result.current[0]).toBe(DEFAULT_MODE);
});
});

View File

@@ -0,0 +1,15 @@
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
const DEFAULT_CURSOR_SYNC_MODE = DashboardCursorSync.Crosshair;
export function useDashboardCursorSyncMode(
dashboardId: string | undefined,
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
return useDashboardPreference(
dashboardId,
'cursorSyncMode',
DEFAULT_CURSOR_SYNC_MODE,
);
}

View File

@@ -0,0 +1,107 @@
import { useCallback, useSyncExternalStore } from 'react';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
// Per-dashboard preferences persisted in localStorage. Add new preference
// fields here as they are introduced.
export type DashboardPreferences = {
cursorSyncMode?: DashboardCursorSync;
};
type DashboardPreferencesStore = Record<string, DashboardPreferences>;
const subscribers = new Set<() => void>();
const subscribe = (callback: () => void): (() => void) => {
subscribers.add(callback);
return (): void => {
subscribers.delete(callback);
};
};
const notify = (): void => {
subscribers.forEach((cb) => cb());
};
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === LOCALSTORAGE.DASHBOARD_PREFERENCES) {
notify();
}
});
}
const readStore = (): DashboardPreferencesStore => {
try {
const raw = getLocalStorageApi(LOCALSTORAGE.DASHBOARD_PREFERENCES);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed as DashboardPreferencesStore;
}
}
} catch (error) {
console.warn(
`Error reading localStorage key "${LOCALSTORAGE.DASHBOARD_PREFERENCES}":`,
error,
);
}
return {};
};
const writeStore = (store: DashboardPreferencesStore): void => {
try {
setLocalStorageApi(LOCALSTORAGE.DASHBOARD_PREFERENCES, JSON.stringify(store));
} catch (error) {
console.warn(
`Error writing localStorage key "${LOCALSTORAGE.DASHBOARD_PREFERENCES}":`,
error,
);
}
};
const readPreference = <K extends keyof DashboardPreferences>(
dashboardId: string | undefined,
key: K,
): DashboardPreferences[K] | undefined => {
if (!dashboardId) {
return undefined;
}
return readStore()[dashboardId]?.[key];
};
export function useDashboardPreference<K extends keyof DashboardPreferences>(
dashboardId: string | undefined,
key: K,
defaultValue: NonNullable<DashboardPreferences[K]>,
): [
NonNullable<DashboardPreferences[K]>,
(value: NonNullable<DashboardPreferences[K]>) => void,
] {
type Value = NonNullable<DashboardPreferences[K]>;
const getSnapshot = useCallback(
(): Value =>
(readPreference(dashboardId, key) as Value | undefined) ?? defaultValue,
[dashboardId, key, defaultValue],
);
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const updateValue = useCallback(
(next: Value) => {
if (!dashboardId) {
return;
}
const store = readStore();
store[dashboardId] = { ...store[dashboardId], [key]: next };
writeStore(store);
notify();
},
[dashboardId, key],
);
return [value, updateValue];
}

View File

@@ -30,8 +30,11 @@ function getCommonGroupByKeys(
}
/**
* Returns the 1-based indexes of every series whose metric matches
* sourceMetric on all commonKeys.
* Returns the 1-based indexes of every visible series whose metric matches
* sourceMetric on all commonKeys. Hidden series (show === false) are
* excluded — a hidden match contributes nothing to the receiver tooltip,
* so treating it as "no match" lets the empty-array path suppress the
* tooltip entirely instead of rendering an empty shell.
*/
function findMatchingSeriesIndexes(
series: uPlot.Series[],
@@ -39,7 +42,7 @@ function findMatchingSeriesIndexes(
commonKeys: string[],
): number[] {
return series.reduce<number[]>((acc, s, i) => {
if (i === 0) {
if (i === 0 || s.show === false) {
return acc;
}
const metric = (s as ExtendedSeries).metric;

View File

@@ -0,0 +1,152 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
import type {
TooltipControllerState,
TooltipSyncMetadata,
} from '../TooltipPlugin/types';
const SYNC_KEY = 'test-sync';
function makeController(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: {},
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
syncedSeriesIndexes: null,
cursorDrivenBySync: false,
plotWithinViewport: true,
windowWidth: 1024,
windowHeight: 768,
pendingPinnedUpdate: false,
};
}
function makeFakePlot(
series: ExtendedSeries[],
cursorEvent: Record<string, unknown> | null = null,
): uPlot {
const root = document.createElement('div');
const yCrosshair = document.createElement('div');
yCrosshair.className = 'u-cursor-y';
root.appendChild(yCrosshair);
return {
root,
series,
cursor: { event: cursorEvent, left: 50 },
setSeries: jest.fn(),
} as unknown as uPlot;
}
const SERVICE_NAME_KEY: BaseAutocompleteData = {
key: 'service.name',
type: 'tag',
};
const groupByService: TooltipSyncMetadata = {
groupBy: [SERVICE_NAME_KEY],
};
function seedSourcePanel(activeMetric: Record<string, string>): void {
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
}
function makeReceiverSeries(
entries: { name: string; show?: boolean }[],
): ExtendedSeries[] {
return [
{} as ExtendedSeries,
...entries.map(
(e) =>
({
show: e.show ?? true,
metric: { 'service.name': e.name },
}) as unknown as ExtendedSeries,
),
];
}
describe('createSyncDisplayHook (receiver-side filtering)', () => {
beforeEach(() => {
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
});
it('returns indexes of visible matching series only', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
});
it('treats all matching series being hidden as no match → empty array', () => {
seedSourcePanel({ 'service.name': 'frontendproxy' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontendproxy', show: false },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
});
it('excludes hidden series and keeps the visible matches', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: false },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
// Focuses the first visible match, not the hidden one at index 1.
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('returns null (no filtering) when the hook runs on the source panel', () => {
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
// cursor.event != null marks this invocation as the source panel.
const plot = makeFakePlot(series, { type: 'mousemove' });
const controller = makeController();
controller.focusedSeriesIndex = 1;
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toBeNull();
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
'service.name': 'flagd',
});
});
});