From dbadd7a685382a0402de7a276a8b3823695b5c6f Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 6 Oct 2025 14:27:11 +0200 Subject: [PATCH] feat: add support for a switch type of dashboard variable --- .../src/selectors/pages.ts | 11 + .../scene/VariableControls.tsx | 28 ++ .../sceneVariablesSetToVariables.test.ts | 195 ++++++++++ .../sceneVariablesSetToVariables.ts | 50 +++ .../transformSaveModelSchemaV2ToScene.ts | 20 +- .../transformSceneToSaveModelSchemaV2.ts | 2 + .../components/SwitchVariableForm.test.tsx | 131 +++++++ .../components/SwitchVariableForm.tsx | 140 ++++++++ .../editors/SwitchVariableEditor.test.tsx | 36 ++ .../editors/SwitchVariableEditor.tsx | 57 +++ .../settings/variables/utils.ts | 17 +- .../dashboard-scene/utils/variables.test.ts | 336 ++++++++++++++++++ .../dashboard-scene/utils/variables.ts | 27 ++ 13 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 581a8294d10..b45357f05f7 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -596,6 +596,17 @@ export const versionedPages = { '11.0.0': 'data-testid ad-hoc filters variable mode toggle', }, }, + SwitchVariable: { + valuePairTypeSelect: { + ['12.3.0']: 'data-testid switch variable value pair type select', + }, + enabledValueInput: { + ['12.3.0']: 'data-testid switch variable enabled value input', + }, + disabledValueInput: { + ['12.3.0']: 'data-testid switch variable disabled value input', + }, + }, }, }, }, diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 45e61146ae1..e285b94e5e1 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -9,6 +9,7 @@ import { SceneVariableState, ControlsLabel, ControlsLayout, + sceneUtils, } from '@grafana/scenes'; import { useElementSelection, useStyles2 } from '@grafana/ui'; @@ -64,6 +65,18 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP } }; + // For switch variables in menu, we want to show the switch on the left and the label on the right + if (inMenu && sceneUtils.isSwitchVariable(variable)) { + return ( +
+
+ +
+ +
+ ); + } + if (inMenu) { return (
@@ -134,6 +147,21 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', flexDirection: 'column', }), + switchMenuContainer: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + switchControl: css({ + '& > div': { + border: 'none', + background: 'transparent', + paddingRight: theme.spacing(0.5), + }, + }), + switchLabel: css({ + marginTop: theme.spacing(0.5), + }), labelWrapper: css({ display: 'flex', alignItems: 'center', diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index e91b28cc646..5b0699cb07b 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -21,6 +21,7 @@ import { IntervalVariable, QueryVariable, SceneVariableSet, + SwitchVariable, TextBoxVariable, } from '@grafana/scenes'; import { DataSourceRef, VariableHide, VariableRefresh } from '@grafana/schema'; @@ -877,6 +878,95 @@ describe('sceneVariablesSetToVariables', () => { }); }); + it('should handle SwitchVariable with true value', () => { + const variable = new SwitchVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + hide: VariableHide.inControlsMenu, + value: true, + skipUrlSync: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "current": { + "text": "True", + "value": "true", + }, + "description": "test-desc", + "hide": 3, + "label": "test-label", + "name": "test", + "skipUrlSync": true, + "type": "switch", + } + `); + }); + + it('should handle SwitchVariable with false value', () => { + const variable = new SwitchVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + hide: VariableHide.inControlsMenu, + value: false, + skipUrlSync: false, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "current": { + "text": "False", + "value": "false", + }, + "description": "test-desc", + "hide": 3, + "label": "test-label", + "name": "test", + "type": "switch", + } + `); + }); + + it('should handle SwitchVariable with minimal configuration', () => { + const variable = new SwitchVariable({ + name: 'minimal', + value: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToVariables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "current": { + "text": "True", + "value": "true", + }, + "description": undefined, + "label": undefined, + "name": "minimal", + "type": "switch", + } + `); + }); + describe('sceneVariablesSetToSchemaV2Variables', () => { it('should handle QueryVariable', () => { const variable = new QueryVariable({ @@ -1455,5 +1545,110 @@ describe('sceneVariablesSetToVariables', () => { expect(() => sceneVariablesSetToSchemaV2Variables(set)).toThrow('Unsupported variable type'); }); }); + + it('should handle SwitchVariable with true value', () => { + const variable = new SwitchVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + hide: VariableHide.inControlsMenu, + value: true, + skipUrlSync: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "SwitchVariable", + "spec": { + "current": true, + "description": "test-desc", + "hide": "inControlsMenu", + "label": "test-label", + "name": "test", + "skipUrlSync": true, + }, + } + `); + }); + + it('should handle SwitchVariable with false value', () => { + const variable = new SwitchVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + hide: VariableHide.inControlsMenu, + value: false, + skipUrlSync: false, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "SwitchVariable", + "spec": { + "current": false, + "description": "test-desc", + "hide": "inControlsMenu", + "label": "test-label", + "name": "test", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle SwitchVariable with minimal configuration', () => { + const variable = new SwitchVariable({ + name: 'minimal', + value: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "SwitchVariable", + "spec": { + "current": true, + "description": undefined, + "hide": "dontHide", + "label": undefined, + "name": "minimal", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle SwitchVariable with string value conversion', () => { + // Test edge case where value might be passed as string + const variable = new SwitchVariable({ + name: 'test', + value: 'true' as any, // Simulating potential string input + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0].spec.current).toBe(true); + }); }); }); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 87cde15a7ea..01fe0927619 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -26,6 +26,7 @@ import { VariableOption, defaultDataQueryKind, AdHocFilterWithLabels, + SwitchVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { getDefaultDatasource } from 'app/features/dashboard/api/ResponseTransformers'; @@ -210,6 +211,24 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio filters: [...validateFiltersOrigin(variable.state.originFilters), ...variable.state.filters], defaultKeys: variable.state.defaultKeys, }); + } else if (sceneUtils.isSwitchVariable(variable)) { + variables.push({ + ...commonProperties, + current: { + value: variable.state.value, + text: variable.state.value, + }, + options: [ + { + value: variable.state.enabledValue, + text: variable.state.enabledValue, + }, + { + value: variable.state.disabledValue, + text: variable.state.disabledValue, + }, + ], + }); } else if (variable.state.type === 'system') { // Not persisted } else { @@ -264,6 +283,7 @@ export function sceneVariablesSetToSchemaV2Variables( | ConstantVariableKind | GroupByVariableKind | AdhocVariableKind + | SwitchVariableKind > { let variables: Array< | QueryVariableKind @@ -274,6 +294,7 @@ export function sceneVariablesSetToSchemaV2Variables( | ConstantVariableKind | GroupByVariableKind | AdhocVariableKind + | SwitchVariableKind > = []; for (const variable of set.state.variables) { @@ -294,6 +315,8 @@ export function sceneVariablesSetToSchemaV2Variables( }; let options: VariableOption[] = []; + + // Query variable if (sceneUtils.isQueryVariable(variable)) { // Not sure if we actually have to still support this option given // that it's not exposed in the UI @@ -355,6 +378,8 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(queryVariable); + + // Custom variable } else if (sceneUtils.isCustomVariable(variable)) { options = variableValueOptionsToVariableOptions(variable.state); const customVariable: CustomVariableKind = { @@ -371,6 +396,8 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(customVariable); + + // Datasource variable } else if (sceneUtils.isDataSourceVariable(variable)) { const datasourceVariable: DatasourceVariableKind = { kind: 'DatasourceVariable', @@ -392,6 +419,8 @@ export function sceneVariablesSetToSchemaV2Variables( } variables.push(datasourceVariable); + + // Constant variable } else if (sceneUtils.isConstantVariable(variable)) { const constantVariable: ConstantVariableKind = { kind: 'ConstantVariable', @@ -407,6 +436,8 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(constantVariable); + + // Interval variable } else if (sceneUtils.isIntervalVariable(variable)) { const intervals = getIntervalsQueryFromNewIntervalModel(variable.state.intervals); const intervalVariable: IntervalVariableKind = { @@ -431,6 +462,8 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(intervalVariable); + + // Textbox variable } else if (sceneUtils.isTextBoxVariable(variable)) { const current = { text: variable.state.value, @@ -447,6 +480,8 @@ export function sceneVariablesSetToSchemaV2Variables( }; variables.push(textBoxVariable); + + // Groupby variable } else if (sceneUtils.isGroupByVariable(variable) && config.featureToggles.groupByVariable) { options = variableValueOptionsToVariableOptions(variable.state); @@ -483,6 +518,8 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(groupVariable); + + // Adhoc variable } else if (sceneUtils.isAdHocVariable(variable)) { const ds = getDataSourceForQuery( variable.state.datasource, @@ -508,6 +545,19 @@ export function sceneVariablesSetToSchemaV2Variables( }, }; variables.push(adhocVariable); + + // Switch variable + } else if (sceneUtils.isSwitchVariable(variable)) { + const switchVariable: SwitchVariableKind = { + kind: 'SwitchVariable', + spec: { + ...commonProperties, + current: variable.state.value, + enabledValue: variable.state.enabledValue, + disabledValue: variable.state.disabledValue, + }, + }; + variables.push(switchVariable); } else if (variable.state.type === 'system') { // Do nothing } else { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index d352c75a5ec..ad94f404e68 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -16,6 +16,7 @@ import { SceneVariable, SceneVariableSet, ScopesVariable, + SwitchVariable, TextBoxVariable, } from '@grafana/scenes'; import { @@ -34,11 +35,13 @@ import { defaultIntervalVariableKind, defaultQueryVariableKind, defaultTextVariableKind, + defaultSwitchVariableKind, GroupByVariableKind, IntervalVariableKind, LibraryPanelKind, PanelKind, QueryVariableKind, + SwitchVariableKind, TextVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; @@ -92,7 +95,8 @@ export type TypedVariableModelV2 = | IntervalVariableKind | CustomVariableKind | GroupByVariableKind - | AdhocVariableKind; + | AdhocVariableKind + | SwitchVariableKind; export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo): DashboardScene { const { spec: dashboard, metadata, apiVersion } = dto; @@ -415,6 +419,15 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S // @ts-expect-error defaultOptions: variable.options, }); + } else if (variable.kind === defaultSwitchVariableKind().kind) { + return new SwitchVariable({ + ...commonProperties, + value: variable.spec.current ?? 'false', + enabledValue: variable.spec.enabledValue ?? 'true', + disabledValue: variable.spec.disabledValue ?? 'false', + skipUrlSync: variable.spec.skipUrlSync, + hide: transformVariableHideToEnumV1(variable.spec.hide), + }); } else { throw new Error(`Scenes: Unsupported variable type ${variable.kind}`); } @@ -520,6 +533,11 @@ export function createSnapshotVariable(variable: TypedVariableModelV2): SceneVar value: '', text: '', }; + } else if (variable.kind === 'SwitchVariable') { + current = { + value: variable.spec.current ?? 'false', + text: variable.spec.current ?? 'false', + }; } else { current = { value: variable.spec.current?.value ?? '', diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index dd92ca69b00..a1d379d9865 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -44,6 +44,7 @@ import { FieldColor, defaultFieldConfig, defaultDataQueryKind, + SwitchVariableKind, } from '../../../../../packages/grafana-schema/src/schema/dashboard/v2'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; @@ -411,6 +412,7 @@ function getVariables(oldDash: DashboardSceneState, dsReferencesMapping?: DSRefe | ConstantVariableKind | GroupByVariableKind | AdhocVariableKind + | SwitchVariableKind > = []; if (variablesSet instanceof SceneVariableSet) { diff --git a/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.test.tsx new file mode 100644 index 00000000000..a5561a5ecd6 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { selectors } from '@grafana/e2e-selectors'; + +import { SwitchVariableForm } from './SwitchVariableForm'; + +describe('SwitchVariableForm', () => { + const onEnabledValueChange = jest.fn(); + const onDisabledValueChange = jest.fn(); + + const defaultProps = { + enabledValue: 'true', + disabledValue: 'false', + onEnabledValueChange, + onDisabledValueChange, + }; + + function renderForm(props = {}) { + return render(); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the form', () => { + render(); + + expect(screen.getByText('Switch options')).toBeInTheDocument(); + expect(screen.getByText('Value pair type')).toBeInTheDocument(); + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.valuePairTypeSelect) + ).toBeInTheDocument(); + }); + + it('should not show custom inputs for predefined value pair types', () => { + renderForm(); + + expect( + screen.queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput) + ).not.toBeInTheDocument(); + }); + + it('should show custom inputs when value pair type is custom', () => { + renderForm({ + enabledValue: 'on', + disabledValue: 'off', + }); + + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput) + ).toBeInTheDocument(); + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput) + ).toBeInTheDocument(); + }); + + it('should call onEnabledValueChange when enabled value input changes', async () => { + const user = userEvent.setup(); + renderForm({ + enabledValue: '', + disabledValue: 'off', + }); + + const enabledInput = screen.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput + ); + await user.type(enabledInput, 't'); + + expect(onEnabledValueChange).toHaveBeenCalledTimes(1); + expect(onEnabledValueChange).toHaveBeenNthCalledWith(1, 't'); + }); + + it('should call onDisabledValueChange when disabled value input changes', async () => { + const user = userEvent.setup(); + renderForm({ + enabledValue: 'on', + disabledValue: '', + }); + + const disabledInput = screen.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput + ); + await user.type(disabledInput, 't'); + + expect(onDisabledValueChange).toHaveBeenCalledTimes(1); + expect(onDisabledValueChange).toHaveBeenCalledWith('t'); + }); + + it('should handle all predefined value pair types correctly', () => { + const testCases = [ + { enabled: 'true', disabled: 'false', expected: 'True / False', hasCustomInputs: false }, + { enabled: '1', disabled: '0', expected: '1 / 0', hasCustomInputs: false }, + { enabled: 'yes', disabled: 'no', expected: 'Yes / No', hasCustomInputs: false }, + { enabled: 'custom', disabled: 'value', expected: 'Custom', hasCustomInputs: true }, + ]; + + testCases.forEach(({ enabled, disabled, expected, hasCustomInputs }) => { + const { unmount } = renderForm({ + enabledValue: enabled, + disabledValue: disabled, + }); + + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.valuePairTypeSelect) + ).toHaveValue(expected); + + if (hasCustomInputs) { + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput) + ).toBeInTheDocument(); + expect( + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput) + ).toBeInTheDocument(); + } else { + expect( + screen.queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput) + ).not.toBeInTheDocument(); + } + + unmount(); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.tsx new file mode 100644 index 00000000000..394ff9b5fa2 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/SwitchVariableForm.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Trans, t } from '@grafana/i18n'; +import { Field, Combobox, Input, type ComboboxOption } from '@grafana/ui'; + +import { VariableLegend } from './VariableLegend'; + +interface SwitchVariableFormProps { + enabledValue: string; + disabledValue: string; + onEnabledValueChange: (value: string) => void; + onDisabledValueChange: (value: string) => void; +} + +const VALUE_PAIR_OPTIONS: Array> = [ + { label: 'True / False', value: 'boolean' }, + { label: '1 / 0', value: 'number' }, + { label: 'Yes / No', value: 'string' }, + { label: 'Custom', value: 'custom' }, +]; + +export function SwitchVariableForm({ + enabledValue, + disabledValue, + onEnabledValueChange, + onDisabledValueChange, +}: SwitchVariableFormProps) { + const currentValuePairType = getCurrentValuePairType(enabledValue, disabledValue); + const [isCustomValuePairType, setIsCustomValuePairType] = useState(currentValuePairType === 'custom'); + + const onValuePairTypeChange = (selection: ComboboxOption | null) => { + if (!selection?.value) { + return; + } + + switch (selection.value) { + case 'boolean': + onEnabledValueChange('true'); + onDisabledValueChange('false'); + setIsCustomValuePairType(false); + break; + case 'number': + onEnabledValueChange('1'); + onDisabledValueChange('0'); + setIsCustomValuePairType(false); + break; + case 'string': + onEnabledValueChange('yes'); + onDisabledValueChange('no'); + setIsCustomValuePairType(false); + break; + case 'custom': + setIsCustomValuePairType(true); + break; + } + }; + + return ( + <> + + Switch options + + + + + + + {/* Custom value pair type */} + {isCustomValuePairType && ( + <> + + { + onEnabledValueChange(event.currentTarget.value); + }} + placeholder={t( + 'dashboard-scene.switch-variable-form.enabled-value-placeholder', + 'e.g. On, Enabled, Active' + )} + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.enabledValueInput} + /> + + + + onDisabledValueChange(event.currentTarget.value)} + placeholder={t( + 'dashboard-scene.switch-variable-form.disabled-value-placeholder', + 'e.g. Off, Disabled, Inactive' + )} + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.SwitchVariable.disabledValueInput} + /> + + + )} + + ); +} + +function getCurrentValuePairType(enabledValue: string, disabledValue: string) { + if (enabledValue === 'true' && disabledValue === 'false') { + return 'boolean'; + } + if (enabledValue === '1' && disabledValue === '0') { + return 'number'; + } + if (enabledValue === 'yes' && disabledValue === 'no') { + return 'string'; + } + return 'custom'; +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.test.tsx new file mode 100644 index 00000000000..46c6987a0d7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SwitchVariable } from '@grafana/scenes'; + +import { SwitchVariableEditor } from './SwitchVariableEditor'; + +describe('SwitchVariableEditor', () => { + it('should render with default value false', () => { + const variable = new SwitchVariable({ name: 'test', value: false }); + render( {}} />); + + const switchElement = screen.getByRole('switch'); + expect(switchElement).not.toBeChecked(); + }); + + it('should render with default value true', () => { + const variable = new SwitchVariable({ name: 'test', value: true }); + render( {}} />); + + const switchElement = screen.getByRole('switch'); + expect(switchElement).toBeChecked(); + }); + + it('should update variable state when switch is clicked', async () => { + const variable = new SwitchVariable({ name: 'test', value: false }); + const user = userEvent.setup(); + + render( {}} />); + + const switchElement = screen.getByRole('switch'); + await user.click(switchElement); + + expect(variable.state.value).toBe(true); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.tsx new file mode 100644 index 00000000000..77d10798eb7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/SwitchVariableEditor.tsx @@ -0,0 +1,57 @@ +import { t } from '@grafana/i18n'; +import { SceneVariable, SwitchVariable } from '@grafana/scenes'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; + +import { SwitchVariableForm } from '../components/SwitchVariableForm'; + +interface SwitchVariableEditorProps { + variable: SwitchVariable; +} + +export function SwitchVariableEditor({ variable }: SwitchVariableEditorProps) { + const { value, enabledValue, disabledValue } = variable.useState(); + + const onEnabledValueChange = (newEnabledValue: string) => { + const isCurrentlyEnabled = value === enabledValue; + + if (isCurrentlyEnabled) { + variable.setState({ enabledValue: newEnabledValue, value: newEnabledValue }); + } else { + variable.setState({ enabledValue: newEnabledValue }); + } + }; + + const onDisabledValueChange = (newDisabledValue: string) => { + const isCurrentlyDisabled = value === disabledValue; + + if (isCurrentlyDisabled) { + variable.setState({ disabledValue: newDisabledValue, value: newDisabledValue }); + } else { + variable.setState({ disabledValue: newDisabledValue }); + } + }; + + return ( + + ); +} + +export function getSwitchVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] { + if (!(variable instanceof SwitchVariable)) { + console.warn('getSwitchVariableOptions: variable is not a SwitchVariable'); + return []; + } + + return [ + new OptionsPaneItemDescriptor({ + title: t('dashboard-scene.switch-variable-form.label-value', 'Default value'), + id: `variable-${variable.state.name}-value`, + render: () => , + }), + ]; +} diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts index 684ba7b1ef0..adb43b42bba 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -18,6 +18,7 @@ import { AdHocFiltersVariable, SceneVariableState, SceneVariableSet, + SwitchVariable, } from '@grafana/scenes'; import { VariableHide, VariableType } from '@grafana/schema'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -31,6 +32,7 @@ import { DataSourceVariableEditor, getDataSourceVariableOptions } from './editor import { getGroupByVariableOptions, GroupByVariableEditor } from './editors/GroupByVariableEditor'; import { getIntervalVariableOptions, IntervalVariableEditor } from './editors/IntervalVariableEditor'; import { getQueryVariableOptions, QueryVariableEditor } from './editors/QueryVariableEditor'; +import { getSwitchVariableOptions, SwitchVariableEditor } from './editors/SwitchVariableEditor'; import { TextBoxVariableEditor, getTextBoxVariableOptions } from './editors/TextBoxVariableEditor'; interface EditableVariableConfig { @@ -117,6 +119,15 @@ export const getEditableVariables: () => Record> { @@ -187,6 +199,8 @@ export function getVariableScene(type: EditableVariableType, initialState: Commo return new GroupByVariable(initialState); case 'textbox': return new TextBoxVariable(initialState); + case 'switch': + return new SwitchVariable(initialState); } } @@ -262,7 +276,8 @@ export function isSceneVariableInstance(sceneObject: SceneObject): sceneObject i sceneUtils.isIntervalVariable(sceneObject) || sceneUtils.isQueryVariable(sceneObject) || sceneUtils.isTextBoxVariable(sceneObject) || - sceneUtils.isGroupByVariable(sceneObject) + sceneUtils.isGroupByVariable(sceneObject) || + sceneUtils.isSwitchVariable(sceneObject) ); } diff --git a/public/app/features/dashboard-scene/utils/variables.test.ts b/public/app/features/dashboard-scene/utils/variables.test.ts index 68c1cd015ca..088e002d26d 100644 --- a/public/app/features/dashboard-scene/utils/variables.test.ts +++ b/public/app/features/dashboard-scene/utils/variables.test.ts @@ -6,6 +6,7 @@ import { IntervalVariableModel, LoadingState, QueryVariableModel, + SwitchVariableModel, TextBoxVariableModel, TypedVariableModel, } from '@grafana/data'; @@ -17,6 +18,7 @@ import { GroupByVariable, QueryVariable, SceneVariableSet, + SwitchVariable, } from '@grafana/scenes'; import { defaultDashboard, defaultTimePickerConfig, VariableType } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; @@ -657,6 +659,340 @@ describe('when creating variables objects', () => { }); }); + it('should migrate switch variable with string true value', () => { + const variable: SwitchVariableModel = { + id: 'switch0', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar', + label: 'Switch Label', + description: 'Switch Description', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: 'true', + value: 'true', + }, + hide: 0, + skipUrlSync: false, + options: [ + { + selected: true, + text: 'true', + value: 'true', + }, + { + selected: false, + text: 'false', + value: 'false', + }, + ], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: 'Switch Description', + hide: 0, + label: 'Switch Label', + name: 'switchVar', + skipUrlSync: false, + type: 'switch', + value: true, + }); + }); + + it('should migrate switch variable with string false value', () => { + const variable: SwitchVariableModel = { + id: 'switch1', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar2', + label: 'Switch Label 2', + description: 'Switch Description 2', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: 'false', + value: 'false', + }, + hide: 1, + skipUrlSync: true, + options: [ + { + selected: false, + text: 'true', + value: 'true', + }, + { + selected: true, + text: 'false', + value: 'false', + }, + ], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: 'Switch Description 2', + hide: 1, + label: 'Switch Label 2', + name: 'switchVar2', + skipUrlSync: true, + type: 'switch', + value: false, + }); + }); + + it('should migrate switch variable with array true value', () => { + const variable: SwitchVariableModel = { + id: 'switch2', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar3', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: ['true'], + value: ['true'], + }, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar3', + skipUrlSync: false, + type: 'switch', + value: true, + }); + }); + + it('should migrate switch variable with array false value', () => { + const variable: SwitchVariableModel = { + id: 'switch3', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar4', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: ['false'], + value: ['false'], + }, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar4', + skipUrlSync: false, + type: 'switch', + value: false, + }); + }); + + it('should migrate switch variable with boolean true value', () => { + const variable: SwitchVariableModel = { + id: 'switch4', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar5', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: true, + value: true, + }, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar5', + skipUrlSync: false, + type: 'switch', + value: true, + }); + }); + + it('should migrate switch variable with boolean false value', () => { + const variable: SwitchVariableModel = { + id: 'switch5', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar6', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: false, + value: false, + }, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar6', + skipUrlSync: false, + type: 'switch', + value: false, + }); + }); + + it('should migrate switch variable with no current value', () => { + const variable: SwitchVariableModel = { + id: 'switch6', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar7', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: undefined, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar7', + skipUrlSync: false, + type: 'switch', + value: false, + }); + }); + + it('should migrate switch variable with array containing non-true value', () => { + const variable: SwitchVariableModel = { + id: 'switch7', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'switchVar8', + type: 'switch', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: ['something'], + value: ['something'], + }, + hide: 0, + skipUrlSync: false, + options: [], + query: '', + multi: false, + includeAll: false, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(SwitchVariable); + expect(rest).toEqual({ + description: undefined, + hide: 0, + label: undefined, + name: 'switchVar8', + skipUrlSync: false, + type: 'switch', + value: false, + }); + }); + it.each(['system'])('should throw for unsupported (yet) variables', (type) => { const variable = { name: 'query0', diff --git a/public/app/features/dashboard-scene/utils/variables.ts b/public/app/features/dashboard-scene/utils/variables.ts index 6b6eeae5e77..f1afeea0ab1 100644 --- a/public/app/features/dashboard-scene/utils/variables.ts +++ b/public/app/features/dashboard-scene/utils/variables.ts @@ -11,6 +11,7 @@ import { SceneVariable, SceneVariableSet, ScopesVariable, + SwitchVariable, TextBoxVariable, } from '@grafana/scenes'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; @@ -156,6 +157,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode ), }); } + // Custom variable if (variable.type === 'custom') { return new CustomVariable({ ...commonProperties, @@ -171,6 +173,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode hide: variable.hide, allowCustomValue: variable.allowCustomValue, }); + // Query variable } else if (variable.type === 'query') { return new QueryVariable({ ...commonProperties, @@ -196,6 +199,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode })), staticOptionsOrder: variable.staticOptionsOrder, }); + // Datasource variable } else if (variable.type === 'datasource') { return new DataSourceVariable({ ...commonProperties, @@ -212,6 +216,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode defaultOptionEnabled: variable.current?.value === DEFAULT_DATASOURCE && variable.current?.text === 'default', allowCustomValue: variable.allowCustomValue, }); + // Interval variable } else if (variable.type === 'interval') { const intervals = getIntervalsFromQueryString(variable.query); const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals); @@ -226,6 +231,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode skipUrlSync: variable.skipUrlSync, hide: variable.hide, }); + // Constant variable } else if (variable.type === 'constant') { return new ConstantVariable({ ...commonProperties, @@ -233,6 +239,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode skipUrlSync: variable.skipUrlSync, hide: variable.hide, }); + // Textbox variable } else if (variable.type === 'textbox') { let val; if (!variable?.current?.value) { @@ -251,6 +258,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode skipUrlSync: variable.skipUrlSync, hide: variable.hide, }); + // Groupby variable } else if (config.featureToggles.groupByVariable && variable.type === 'groupby') { return new GroupByVariable({ ...commonProperties, @@ -264,6 +272,25 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode defaultValue: variable.defaultValue, allowCustomValue: variable.allowCustomValue, }); + // Switch variable + // In the old variable model we are storing the enabled and disabled values in the options: + // the first option is the enabled value and the second is the disabled value + } else if (variable.type === 'switch') { + const pickFirstValue = (value: string | string[]) => { + if (Array.isArray(value)) { + return value[0]; + } + return value; + }; + + return new SwitchVariable({ + ...commonProperties, + value: pickFirstValue(variable.current?.value), + enabledValue: pickFirstValue(variable.options?.[0]?.value), + disabledValue: pickFirstValue(variable.options?.[1]?.value), + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + }); } else { throw new Error(`Scenes: Unsupported variable type ${variable.type}`); }