Compare commits

...

6 Commits

Author SHA1 Message Date
Ashwin Bhatkal
1a062b3fb1 enh(dashboard-v2): show the variable clear only where it can act
The clear icon appeared on hover of the closed control — an action in a row of
variable pills whose result you cannot see, since the list it clears is not on
screen — and it committed the default immediately in that state. It was also offered
while ALL was selected, where the shared control refuses to empty the selection, so
the icon sat there doing nothing.

It now exists only while the list is open, and not while every option is selected:
unchecking ALL in the list is the way out of that. Clicking it empties the list and
commits nothing; closing fills in whatever the variable should hold — its configured
default, else ALL where it offers one, else the first option.
2026-07-30 22:00:36 +05:30
Ashwin Bhatkal
39bf182213 fix(dashboard-v2): stop a single-select dynamic variable blocking every save
Saving forced `allowAllValue: true` for every dynamic variable while
`allowMultiple` followed the model, so a single-select one went out with ALL set
and no multi-select. The API rejects that combination, and because a save
re-serializes the whole variable list, one such variable failed every edit to the
dashboard — the error even points at that variable's index rather than the one
being edited, which makes it look unrelated.

ALL is a set of values, so it now requires multi-select for every type. Dynamic
variables still always expose it when they are multi-select (V1 parity).
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
2247968c62 perf(dashboard-v2): settle a variable load without a redundant write
A load committed the same selection twice and refetched everything downstream once
for nothing:

- a CUSTOM variable's options need no request, yet the seed stored ALL as a flag and
  left the post-fetch reconcile to expand it into the concrete array;
- that reconcile runs on a selector's first render, before the seed commits, so its
  auto-fill arrived already satisfied — and the flush wrote it and cascaded anyway.

The seed now resolves against options it already knows (materializing ALL, dropping
values the list no longer offers), and an auto-fill the store already satisfies is
dropped, with the cascade narrowed to the variables that actually moved.

Measured on a dashboard with a text and a custom multi-select variable: a settled
load goes from two store writes and one dependent refetch to one write and none, and
a re-render from a dashboard refetch adds neither.
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
d2e10d1d9c fix(dashboard-v2): keep ALL readable while a variable's options load
The control renders an ALL selection from the option array, so until those options
arrive there is nothing to show and it falls back to its placeholder: the variable
reads "Select value" next to a spinner, as though nothing were selected. Concrete
values do not have this problem — they render from the selection itself.

Say ALL in that slot while the options are pending. Display only, so it can never
be committed as a value.
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
90205a376f fix(dashboard-v2): skip the cascade when a variable's value is unchanged
Committing a selection always wrote the store and enqueued the variable's
dependents, so re-picking the value a variable already held refetched every
dependent variable and re-ran every panel query for nothing. Guarded at the single
choke point, order-independently.
2026-07-30 17:37:46 +05:30
Ashwin Bhatkal
59c4f5c7e3 fix(dashboard-v2): apply a variable's configured default reliably
Three separate ways a variable ignored the default its definition carries:

- the reconcile predates the ALL option and filled `options[0]` whenever nothing
  was selected, so an ALL-enabled multi-select never landed on ALL;
- the seed treated any stored entry as a selection, though a selection object is
  truthy even holding '' or [] — an entry persisted empty shadowed the default
  forever, so editing a textbox variable's value in settings never took effect;
- the bar synthesised an empty selection for the render that precedes the seed, and
  the textbox snapshots what it is handed into local state on mount, so a variable
  carrying a value opened with an empty box that filled only on focus + blur.

Precedence is now the same everywhere, through the one resolver: the configured
default first, then ALL where the variable offers it, then the first option. A
stored ALL still wins, even before its option array materializes.

The seed also stops rewriting the store when it resolves to what is already there —
it re-runs on every spec identity change, and an identical write re-rendered every
selection subscriber for nothing.
2026-07-30 17:37:46 +05:30
16 changed files with 774 additions and 43 deletions

View File

@@ -0,0 +1,97 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,9 +133,14 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -0,0 +1,152 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,6 +9,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -65,7 +66,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
defaultValue={textDefault(variable)}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,6 +31,17 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,11 +54,26 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -94,8 +109,11 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
allowClear
placeholder="Select value"
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -129,12 +147,10 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import type {
SelectedVariableValue,
VariableSelection,
@@ -18,6 +24,44 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
seed(
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
} else {
seeded[variable.name] = resolveDefaultSelection(variable);
seed(resolveDefaultSelection(variable));
}
});
setVariableValues(dashboardId, seeded);
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,14 +1,11 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -30,14 +27,12 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,6 +12,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -103,6 +104,10 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -124,8 +129,19 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -0,0 +1,23 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
function textDefault(model: VariableFormModel): string {
export function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,15 +53,31 @@ function isAllDefault(
);
}
/** The configured default (or first option) as a fresh selection. */
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
return {
value: model.multiSelect ? [initial] : initial,
value: model.multiSelect ? [options[0]] : options[0],
allSelected: false,
};
}