From 198f4dbf93980353565b69ca6ddb347e00ae32bd Mon Sep 17 00:00:00 2001 From: grafakus Date: Thu, 13 Nov 2025 14:08:00 +0100 Subject: [PATCH 01/16] feat: WiP --- .../kinds/v2beta1/dashboard_spec.cue | 7 +- .../grafana-data/src/types/templateVars.ts | 1 + .../sceneVariablesSetToVariables.ts | 2 + .../transformSaveModelSchemaV2ToScene.ts | 6 +- .../settings/variables/VariableEditorForm.tsx | 4 +- .../components/CustomVariableForm.tsx | 119 +++++++++++++++++- .../components/QueryVariableForm.tsx | 112 +++++++++++------ .../components/SelectionOptionsForm.tsx | 31 +++-- .../components/VariableValuesPreview.tsx | 53 +++++--- .../CustomVariableEditor.tsx | 23 +++- .../features/dashboard-scene/utils/clone.ts | 1 + 11 files changed, 280 insertions(+), 79 deletions(-) diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index bfc23ad87cb..36c98d8f89e 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -113,7 +113,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -376,7 +376,7 @@ FetchOptions: { url: string body?: string // These are 2D arrays of strings, each representing a key-value pair - // We are defining them this way because we can't generate a go struct that + // We are defining them this way because we can't generate a go struct that // that would have exactly two strings in each sub-array queryParams?: [...[...string]] headers?: [...[...string]] @@ -386,7 +386,7 @@ InfinityOptions: FetchOptions & { datasourceUid: string } -HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" // Action variable type ActionVariableType: "string" @@ -903,6 +903,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: string } // Custom variable kind diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index e6feea4dd3f..f4e3f2ccd44 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions { export interface CustomVariableModel extends VariableWithMultiSupport { type: 'custom'; + valuesFormat?: 'csv' | 'json'; } export interface DataSourceVariableModel extends VariableWithMultiSupport { diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 01fe0927619..64fe4c49293 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -111,6 +111,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio allValue: variable.state.allValue, includeAll: variable.state.includeAll, allowCustomValue: variable.state.allowCustomValue, + valuesFormat: variable.state.valuesFormat, }); } else if (sceneUtils.isDataSourceVariable(variable)) { variables.push({ @@ -393,6 +394,7 @@ export function sceneVariablesSetToSchemaV2Variables( allValue: variable.state.allValue, includeAll: variable.state.includeAll ?? false, allowCustomValue: variable.state.allowCustomValue ?? true, + valuesFormat: variable.state.valuesFormat, }, }; variables.push(customVariable); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 2a2f96caa30..d536fdd1875 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -321,12 +321,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S supportsMultiValueOperators: Boolean(getDataSourceSrv().getInstanceSettings(ds)?.meta.multiValueFilterOperators), }); } + if (variable.kind === defaultCustomVariableKind().kind) { return new CustomVariable({ ...commonProperties, value: variable.spec.current?.value ?? '', text: variable.spec.current?.text ?? '', - query: variable.spec.query, isMulti: variable.spec.multi, allValue: variable.spec.allValue || undefined, @@ -334,6 +334,10 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S defaultToAll: Boolean(variable.spec.includeAll), skipUrlSync: variable.spec.skipUrlSync, hide: transformVariableHideToEnumV1(variable.spec.hide), + valuesFormat: + variable.spec.valuesFormat === 'csv' || variable.spec.valuesFormat === 'json' + ? variable.spec.valuesFormat + : undefined, }); } else if (variable.kind === defaultQueryVariableKind().kind) { return new QueryVariable({ diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index a261cd00852..765710f37aa 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { SceneVariable } from '@grafana/scenes'; import { VariableHide, defaultVariableModel } from '@grafana/schema'; -import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui'; import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect'; import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField'; @@ -123,7 +123,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete {EditorToRender && } - {isHasVariableOptions && } + {isHasVariableOptions && }
diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index b3c78330156..8cbf2c847ae 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -1,7 +1,10 @@ -import { FormEvent } from 'react'; +import { isObject } from 'lodash'; +import { FormEvent, useState } from 'react'; +import { CustomVariableModel, shallowCompare } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; +import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui'; import { SelectionOptionsForm } from './SelectionOptionsForm'; import { VariableLegend } from './VariableLegend'; @@ -9,6 +12,7 @@ import { VariableTextAreaField } from './VariableTextAreaField'; interface CustomVariableFormProps { query: string; + valuesFormat?: CustomVariableModel['valuesFormat']; multi: boolean; allValue?: string | null; includeAll: boolean; @@ -20,10 +24,12 @@ interface CustomVariableFormProps { onQueryBlur?: (event: FormEvent) => void; onAllValueBlur?: (event: FormEvent) => void; onAllowCustomValueChange?: (event: FormEvent) => void; + onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void; } export function CustomVariableForm({ query, + valuesFormat, multi, allValue, includeAll, @@ -33,23 +39,70 @@ export function CustomVariableForm({ onIncludeAllChange, onAllValueChange, onAllowCustomValueChange, + onValuesFormatChange, }: CustomVariableFormProps) { + const [validationError, setValidationError] = useState(); + + const onChangeFormat = (newFormat: CustomVariableModel['valuesFormat']) => { + onValuesFormatChange?.(newFormat); + setValidationError(undefined); + }; + + const onQueryBlur = (e: FormEvent) => { + if (valuesFormat === 'json') { + setValidationError(validateJsonQuery(e.currentTarget.value)); + } + onQueryChange(e); + }; + return ( <> Custom options + + + {valuesFormat === 'json' && ( + + + + )} + + + {validationError && {validationError.message}} + Selection options @@ -58,6 +111,8 @@ export function CustomVariableForm({ includeAll={includeAll} allValue={allValue} allowCustomValue={allowCustomValue} + disableAllowCustomValue={valuesFormat === 'json'} + disableCustomAllValue={valuesFormat === 'json'} onMultiChange={onMultiChange} onIncludeAllChange={onIncludeAllChange} onAllValueChange={onAllValueChange} @@ -66,3 +121,57 @@ export function CustomVariableForm({ ); } + +function TooltipJsonFormat() { + return ( + // TODO: add translation + + Provide a JSON representing an array of objects, where each object can have any number of properties. +
+ Check{' '} + + our docs + {' '} + for more information. +
+ ); +} + +const validateJsonQuery = (rawQuwey: string): Error | undefined => { + const query = rawQuwey.trim(); + if (!query) { + return; + } + + try { + const options = JSON.parse(query); + + if (!Array.isArray(options)) { + throw new Error('Invalid JSON array!'); + } + + if (!options.length) { + return; + } + + const keys = Object.keys(options[0]); + if (!keys.length || !keys.some((k) => k === 'value')) { + throw new Error('The objects in the array must have at least a "value" key!'); + } + + for (let i = 0; i < options.length; i += 1) { + if (!isObject(options[i])) { + throw new Error(`All items in the array must be objects. Item at index=${i} is incorrect!`); + } + + if (!shallowCompare(keys, Object.keys(options[i]))) { + throw new Error(`All objects in the array must have the same keys. Object at index=${i} is incorrect!`); + } + } + + return; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return error as Error; + } +}; diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx index d6d4d467c9a..837a1877330 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -1,4 +1,4 @@ -import { FormEvent } from 'react'; +import { FormEvent, useState } from 'react'; import { useAsync } from 'react-use'; import { DataSourceInstanceSettings, SelectableValue, TimeRange } from '@grafana/data'; @@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; import { QueryVariable } from '@grafana/scenes'; import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; -import { Field, TextLink } from '@grafana/ui'; +import { Box, Field, Switch, TextLink } from '@grafana/ui'; import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor'; import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; @@ -15,9 +15,9 @@ import { getVariableQueryEditor } from 'app/features/variables/editor/getVariabl import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect'; import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect'; import { + QueryVariableStaticOptions, StaticOptionsOrderType, StaticOptionsType, - QueryVariableStaticOptions, } from 'app/features/variables/query/QueryVariableStaticOptions'; import { VariableLegend } from './VariableLegend'; @@ -94,6 +94,16 @@ export function QueryVariableEditorForm({ const { datasource, VariableQueryEditor } = dsConfig ?? {}; + // TODO: remove me after finished testing - each DS can/should implement their own UI + const [returnsMultiProps, setReturnsMultiProps] = useState(false); + const onChangeReturnsMultipleProps = (e: FormEvent) => { + setReturnsMultiProps(e.currentTarget.checked); + onAllowCustomValueChange?.({ currentTarget: { checked: false } }); + onAllValueChange({ currentTarget: { value: '' } }); + onRegExChange({ currentTarget: { value: '' } }); + onStaticOptionsChange?.([]); + }; + return ( <> @@ -102,48 +112,70 @@ export function QueryVariableEditorForm({ {datasource && VariableQueryEditor && ( - + + + + Check{' '} + + our docs + {' '} + for more information. + + } + noMargin + > + + + )} - - - Optional, if you want to extract part of a series name or metric node segment. - -
- - Named capture groups can be used to separate the display text and value ( - - see examples - - ). - -
- } - // eslint-disable-next-line @grafana/i18n/no-untranslated-strings - placeholder="/.*-(?.*)-(?.*)-.*/" - onBlur={onRegExChange} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} - width={52} - /> + {!returnsMultiProps && ( + + + Optional, if you want to extract part of a series name or metric node segment. + +
+ + Named capture groups can be used to separate the display text and value ( + + see examples + + ). + + + } + // eslint-disable-next-line @grafana/i18n/no-untranslated-strings + placeholder="/.*-(?.*)-(?.*)-.*/" + onBlur={onRegExChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} + width={52} + /> + )} - {onStaticOptionsChange && onStaticOptionsOrderChange && ( + {!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && ( ) => void; onAllowCustomValueChange?: (event: ChangeEvent) => void; onIncludeAllChange: (event: ChangeEvent) => void; @@ -20,8 +22,10 @@ interface SelectionOptionsFormProps { export function SelectionOptionsForm({ multi, allowCustomValue, + disableAllowCustomValue, includeAll, allValue, + disableCustomAllValue, onMultiChange, onAllowCustomValueChange, onIncludeAllChange, @@ -39,18 +43,19 @@ export function SelectionOptionsForm({ onChange={onMultiChange} testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch} /> - {onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup - - )} + {!disableAllowCustomValue && + onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup + + )} - {includeAll && ( + {!disableCustomAllValue && includeAll && ( { +export const VariableValuesPreview = ({ variable }: Props) => { + const options = variable.getOptionsForSelect(false); + if (!options.length) { + return null; + } + + if ('valuesFormat' in variable.state && variable.state.valuesFormat === 'json') { + return ; + } + + return ; +}; +VariableValuesPreview.displayName = 'VariableValuesPreview'; + +function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); + const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties })); + const columns = Object.keys(data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const })); + + return ( +
+ + Preview of values + + String(r.value)} pageSize={2} /> +
+ ); +} + +function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); const [previewLimit, setPreviewLimit] = useState(20); const [previewOptions, setPreviewOptions] = useState([]); const showMoreOptions = useCallback( @@ -21,15 +51,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = }, [previewLimit, setPreviewLimit] ); - const styles = useStyles2(getStyles); useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]); - if (!previewOptions.length) { - return null; - } - return ( -
+
Preview of values @@ -51,12 +76,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = )}
); -}; -VariableValuesPreview.displayName = 'VariableValuesPreview'; +} +VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview'; function getStyles(theme: GrafanaTheme2) { return { - wrapper: css({ + previewContainer: css({ display: 'flex', flexDirection: 'column', marginTop: theme.spacing(2), diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index 8dfc7b3eac1..59f1f8ac8c5 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -1,5 +1,6 @@ -import { FormEvent, useCallback } from 'react'; +import { FormEvent, useCallback, useState } from 'react'; +import { CustomVariableModel } from '@grafana/data'; import { t } from '@grafana/i18n'; import { CustomVariable, SceneVariable } from '@grafana/scenes'; @@ -14,7 +15,23 @@ interface CustomVariableEditorProps { } export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) { - const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + + const [prevQuery, setPrevQuery] = useState(''); + const onValuesFormatChange = useCallback( + (format: CustomVariableModel['valuesFormat']) => { + variable.setState({ valuesFormat: format }); + variable.setState({ allowCustomValue: false }); + variable.setState({ allValue: undefined }); + + variable.setState({ query: prevQuery }); + if (query !== prevQuery) { + setPrevQuery(query); + } + onRunQuery(); + }, + [onRunQuery, prevQuery, query, variable] + ); const onMultiChange = useCallback( (event: FormEvent) => { @@ -55,6 +72,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi return ( ); } diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts index 9923357bf5d..ceee21b56a3 100644 --- a/public/app/features/dashboard-scene/utils/clone.ts +++ b/public/app/features/dashboard-scene/utils/clone.ts @@ -43,6 +43,7 @@ export function getLocalVariableValueSet( name: variable.state.name, value, text, + properties: variable.state.options.find((o) => o.value === value)?.properties, isMulti: variable.state.isMulti, includeAll: variable.state.includeAll, }), From 7a0e64196b20cec7e1a6c275dd8265cad9a274c2 Mon Sep 17 00:00:00 2001 From: grafakus Date: Thu, 13 Nov 2025 14:19:03 +0100 Subject: [PATCH 02/16] Update v2 schema + gen types --- .../pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go | 2 ++ .../pkg/apis/dashboard/v1beta1/dashboard_object_gen.go | 2 ++ .../pkg/apis/dashboard/v2beta1/dashboard_spec.cue | 7 ++++--- .../pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go | 1 + apps/dashboard/pkg/apis/dashboard_manifest.go | 2 ++ .../src/schema/dashboard/v2beta1/types.spec.gen.ts | 1 + 6 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go index a267e0c8df8..50847df87c3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go @@ -294,6 +294,8 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types + + // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go index be021b5f003..1423c7b0603 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go @@ -294,6 +294,8 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types + + // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index d9df0dd3ee3..a33015f4cad 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -117,7 +117,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -383,7 +383,7 @@ FetchOptions: { url: string body?: string // These are 2D arrays of strings, each representing a key-value pair - // We are defining them this way because we can't generate a go struct that + // We are defining them this way because we can't generate a go struct that // that would have exactly two strings in each sub-array queryParams?: [...[...string]] headers?: [...[...string]] @@ -393,7 +393,7 @@ InfinityOptions: FetchOptions & { datasourceUid: string } -HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" // Action variable type ActionVariableType: "string" @@ -910,6 +910,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: string } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index fc0a8fcf26f..53896786674 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1677,6 +1677,7 @@ type DashboardCustomVariableSpec struct { SkipUrlSync bool `json:"skipUrlSync"` Description *string `json:"description,omitempty"` AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *string `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index 1409424fbe2..3cbbd7fccfe 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -21,6 +21,8 @@ import ( v2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) +var () + var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 00e9111453a..cbc6da2c924 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -1330,6 +1330,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; + valuesFormat?: string; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ From 4d29e5bf6af88222a360666f90c7669214c5698e Mon Sep 17 00:00:00 2001 From: grafakus Date: Thu, 13 Nov 2025 20:16:47 +0100 Subject: [PATCH 03/16] chore: ... --- .../settings/variables/components/CustomVariableForm.tsx | 4 ++-- .../editors/CustomVariableEditor/CustomVariableEditor.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index 8cbf2c847ae..fb06a020186 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -137,8 +137,8 @@ function TooltipJsonFormat() { ); } -const validateJsonQuery = (rawQuwey: string): Error | undefined => { - const query = rawQuwey.trim(); +const validateJsonQuery = (rawQuery: string): Error | undefined => { + const query = rawQuery.trim(); if (!query) { return; } diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index 59f1f8ac8c5..da97d2a1fe7 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -20,15 +20,15 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const [prevQuery, setPrevQuery] = useState(''); const onValuesFormatChange = useCallback( (format: CustomVariableModel['valuesFormat']) => { + variable.setState({ query: prevQuery }); variable.setState({ valuesFormat: format }); variable.setState({ allowCustomValue: false }); variable.setState({ allValue: undefined }); + onRunQuery(); - variable.setState({ query: prevQuery }); if (query !== prevQuery) { setPrevQuery(query); } - onRunQuery(); }, [onRunQuery, prevQuery, query, variable] ); From 5b685373aadd8adc0555674e8d4b09454998a843 Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 18 Nov 2025 14:42:56 +0100 Subject: [PATCH 04/16] Strengthen valuesFormat type + cleanup generated files --- .../kinds/v2beta1/dashboard_spec.cue | 2 +- .../v0alpha1/dashboard_object_gen.go | 2 -- .../dashboard/v1beta1/dashboard_object_gen.go | 2 -- .../apis/dashboard/v2beta1/dashboard_spec.cue | 2 +- .../dashboard/v2beta1/dashboard_spec_gen.go | 34 ++++++++++++------- apps/dashboard/pkg/apis/dashboard_manifest.go | 2 -- .../dashboard/v2beta1/types.spec.gen.ts | 2 +- 7 files changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index ab6a67d54c7..87c8ad1ef1a 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -906,7 +906,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true - valuesFormat?: string + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go index 50847df87c3..a267e0c8df8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go @@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types - - // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go index 1423c7b0603..be021b5f003 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go @@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types - - // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index a33015f4cad..9f73e765b43 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -910,7 +910,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true - valuesFormat?: string + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 53896786674..4e5a6946cf6 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1665,19 +1665,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind { // Custom variable specification // +k8s:openapi-gen=true type DashboardCustomVariableSpec struct { - Name string `json:"name"` - Query string `json:"query"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` - ValuesFormat *string `json:"valuesFormat,omitempty"` + Name string `json:"name"` + Query string `json:"query"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + AllValue *string `json:"allValue,omitempty"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. @@ -2092,6 +2092,14 @@ const ( DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" ) +// +k8s:openapi-gen=true +type DashboardCustomVariableSpecValuesFormat string + +const ( + DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv" + DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json" +) + // +k8s:openapi-gen=true type DashboardPanelKindOrLibraryPanelKind struct { PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index 3cbbd7fccfe..1409424fbe2 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -21,8 +21,6 @@ import ( v2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" ) -var () - var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index cbc6da2c924..6029c90e61c 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -1330,7 +1330,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; - valuesFormat?: string; + valuesFormat?: "csv" | "json"; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ From 2d17de23950ff848f46c1266ed2d5a288d5fbdb3 Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 18 Nov 2025 14:58:28 +0100 Subject: [PATCH 05/16] Small preview fix when "All" option is checked --- .../settings/variables/components/VariableValuesPreview.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx index 7527886f524..6a07db679c9 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx @@ -28,15 +28,15 @@ VariableValuesPreview.displayName = 'VariableValuesPreview'; function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) { const styles = useStyles2(getStyles); const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties })); - const columns = Object.keys(data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const })); + // the first item in data may be the "All" option which does not have any extra properties, so we try the 2nd item to determine the column names + const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const })); return (
Preview of values - {/* TODO: pageSize=20 */} - String(r.value)} pageSize={2} /> + String(r.value)} pageSize={10} />
); } From 6b7fac65b1b12f2ba35f5fcc6724f4d9726dbb73 Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 18 Nov 2025 15:04:44 +0100 Subject: [PATCH 06/16] chore: Add comment --- .../settings/variables/components/VariableValuesPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx index 6a07db679c9..41a9d5c4fc3 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx @@ -28,7 +28,7 @@ VariableValuesPreview.displayName = 'VariableValuesPreview'; function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) { const styles = useStyles2(getStyles); const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties })); - // the first item in data may be the "All" option which does not have any extra properties, so we try the 2nd item to determine the column names + // the first item in data may be the "All" option, which does not have any extra properties, so we try the 2nd item to determine the column names const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const })); return ( From 3dcd809aaf94e7398f7b193e18358f767773167e Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 18 Nov 2025 18:35:49 +0100 Subject: [PATCH 07/16] Translate CustomVariableEditor + improve JSON validation --- .../transformSaveModelSchemaV2ToScene.ts | 5 +- .../components/CustomVariableForm.tsx | 72 +++---------------- .../CustomVariableEditor.tsx | 62 +++++++++++++++- public/locales/en-US/grafana.json | 2 + 4 files changed, 71 insertions(+), 70 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index d0ec5616de5..e508a5f77ea 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -335,10 +335,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S defaultToAll: Boolean(variable.spec.includeAll), skipUrlSync: variable.spec.skipUrlSync, hide: transformVariableHideToEnumV1(variable.spec.hide), - valuesFormat: - variable.spec.valuesFormat === 'csv' || variable.spec.valuesFormat === 'json' - ? variable.spec.valuesFormat - : undefined, + valuesFormat: variable.spec.valuesFormat || 'csv', }); } else if (variable.kind === defaultQueryVariableKind().kind) { return new QueryVariable({ diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index fb06a020186..84f89df1c6a 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -1,7 +1,6 @@ -import { isObject } from 'lodash'; -import { FormEvent, useState } from 'react'; +import { FormEvent } from 'react'; -import { CustomVariableModel, shallowCompare } from '@grafana/data'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui'; @@ -17,6 +16,7 @@ interface CustomVariableFormProps { allValue?: string | null; includeAll: boolean; allowCustomValue?: boolean; + queryValidationError?: Error; onQueryChange: (event: FormEvent) => void; onMultiChange: (event: FormEvent) => void; onIncludeAllChange: (event: FormEvent) => void; @@ -34,6 +34,7 @@ export function CustomVariableForm({ allValue, includeAll, allowCustomValue, + queryValidationError, onQueryChange, onMultiChange, onIncludeAllChange, @@ -41,20 +42,6 @@ export function CustomVariableForm({ onAllowCustomValueChange, onValuesFormatChange, }: CustomVariableFormProps) { - const [validationError, setValidationError] = useState(); - - const onChangeFormat = (newFormat: CustomVariableModel['valuesFormat']) => { - onValuesFormatChange?.(newFormat); - setValidationError(undefined); - }; - - const onQueryBlur = (e: FormEvent) => { - if (valuesFormat === 'json') { - setValidationError(validateJsonQuery(e.currentTarget.value)); - } - onQueryChange(e); - }; - return ( <> @@ -64,7 +51,7 @@ export function CustomVariableForm({ - {validationError && {validationError.message}} + {queryValidationError && {queryValidationError.message}} Selection options @@ -124,8 +110,7 @@ export function CustomVariableForm({ function TooltipJsonFormat() { return ( - // TODO: add translation - + Provide a JSON representing an array of objects, where each object can have any number of properties.
Check{' '} @@ -136,42 +121,3 @@ function TooltipJsonFormat() {
); } - -const validateJsonQuery = (rawQuery: string): Error | undefined => { - const query = rawQuery.trim(); - if (!query) { - return; - } - - try { - const options = JSON.parse(query); - - if (!Array.isArray(options)) { - throw new Error('Invalid JSON array!'); - } - - if (!options.length) { - return; - } - - const keys = Object.keys(options[0]); - if (!keys.length || !keys.some((k) => k === 'value')) { - throw new Error('The objects in the array must have at least a "value" key!'); - } - - for (let i = 0; i < options.length; i += 1) { - if (!isObject(options[i])) { - throw new Error(`All items in the array must be objects. Item at index=${i} is incorrect!`); - } - - if (!shallowCompare(keys, Object.keys(options[i]))) { - throw new Error(`All objects in the array must have the same keys. Object at index=${i} is incorrect!`); - } - } - - return; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return error as Error; - } -}; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index da97d2a1fe7..6c3774407f1 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -1,6 +1,7 @@ +import { isObject } from 'lodash'; import { FormEvent, useCallback, useState } from 'react'; -import { CustomVariableModel } from '@grafana/data'; +import { CustomVariableModel, shallowCompare } from '@grafana/data'; import { t } from '@grafana/i18n'; import { CustomVariable, SceneVariable } from '@grafana/scenes'; @@ -16,6 +17,7 @@ interface CustomVariableEditorProps { export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) { const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const [queryValidationError, setQueryValidationError] = useState(); const [prevQuery, setPrevQuery] = useState(''); const onValuesFormatChange = useCallback( @@ -26,6 +28,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi variable.setState({ allValue: undefined }); onRunQuery(); + setQueryValidationError(undefined); if (query !== prevQuery) { setPrevQuery(query); } @@ -49,10 +52,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const onQueryChange = useCallback( (event: FormEvent) => { + if (valuesFormat === 'json') { + const validationError = validateJsonQuery(event.currentTarget.value); + setQueryValidationError(validationError); + if (validationError) { + return; + } + } + variable.setState({ query: event.currentTarget.value }); onRunQuery(); }, - [variable, onRunQuery] + [valuesFormat, variable, onRunQuery] ); const onAllValueChange = useCallback( @@ -77,9 +88,10 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi allValue={allValue ?? ''} includeAll={!!includeAll} allowCustomValue={allowCustomValue} + queryValidationError={queryValidationError} + onQueryChange={onQueryChange} onMultiChange={onMultiChange} onIncludeAllChange={onIncludeAllChange} - onQueryChange={onQueryChange} onAllValueChange={onAllValueChange} onAllowCustomValueChange={onAllowCustomValueChange} onValuesFormatChange={onValuesFormatChange} @@ -100,3 +112,47 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt }), ]; } + +const validateJsonQuery = (rawQuery: string): Error | undefined => { + const query = rawQuery.trim(); + if (!query) { + return; + } + + try { + const options = JSON.parse(query); + + if (!Array.isArray(options)) { + throw new Error('Enter a valid JSON array of objects'); + } + + if (!options.length) { + return; + } + + let errorIndex = options.findIndex((item) => !isObject(item)); + if (errorIndex !== -1) { + throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`); + } + + const keys = Object.keys(options[0]); + if (!keys.includes('value')) { + throw new Error('Each object in the array must include at least a "value" property'); + } + if (keys.includes('')) { + throw new Error('Object property names cannot be empty strings'); + } + + errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o))); + if (errorIndex !== -1) { + throw new Error( + `All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties` + ); + } + + return; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return error as Error; + } +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2707b681ae4..8f32a853de3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5813,6 +5813,8 @@ }, "custom-variable-form": { "custom-options": "Custom options", + "json-values-tooltip": "Provide a JSON representing an array of objects, where each object can have any number of properties.
Check <4>our docs for more information.", + "name-json-values": "Object values in a JSON array", "name-values-separated-comma": "Values separated by comma", "selection-options": "Selection options" }, From ad73303328474c4433d1806e82f437526c2ec99a Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 18 Nov 2025 19:36:08 +0100 Subject: [PATCH 08/16] VariableEditorForm checks to display preview with multiple props --- .../settings/variables/VariableEditorForm.tsx | 8 ++++---- .../settings/variables/components/QueryVariableForm.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index a3b0be31e80..53d5ee64915 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -68,7 +68,9 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete const onHideChange = (hide: VariableHide) => variable.setState({ hide }); const isHasVariableOptions = hasVariableOptions(variable); - const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json'; + const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : []; + const hasJsonValuesFormat = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json'; + const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties)); const onDeleteVariable = (hideModal: () => void) => () => { reportInteraction('Delete variable'); @@ -124,9 +126,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete {EditorToRender && } - {isHasVariableOptions && ( - - )} + {isHasVariableOptions && }
diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx index 837a1877330..d9b29b13b37 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -127,11 +127,11 @@ export function QueryVariableEditorForm({ VariableQueryEditor={VariableQueryEditor} timeRange={timeRange} /> + {/* TODO: remove me after finished testing - each DS can/should implement their own UI */} Check{' '} From 694e88b95b5e751a53d064d8685c1f329e8850ea Mon Sep 17 00:00:00 2001 From: grafakus Date: Wed, 19 Nov 2025 08:48:55 +0100 Subject: [PATCH 09/16] Add some unit tests --- .../src/schema/dashboard/v2_examples.ts | 1 + .../DashboardSceneSerializer.test.ts | 1 + .../transformSceneToSaveModel.test.ts.snap | 5 ++ ...sformSceneToSaveModelSchemaV2.test.ts.snap | 1 + .../sceneVariablesSetToVariables.test.ts | 2 + .../components/CustomVariableForm.test.tsx | 70 ++++++++++++++++++- .../dashboard-scene/utils/variables.test.ts | 1 + 7 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 651d858e799..4186fccd0ce 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -327,6 +327,7 @@ export const handyTestingSchema: Spec = { query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 9914559093a..a0d723a5edc 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -844,6 +844,7 @@ describe('DashboardSceneSerializer', () => { query: 'app1', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, ]); diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index f119b3c611c..fb6d2b8d259 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -679,6 +680,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "A,B,C,D,E,F,E,G,H,I,J,K,L", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -697,6 +699,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5", "type": "custom", + "valuesFormat": "csv", }, ], }, @@ -1019,6 +1022,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -1378,6 +1382,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 6bddc68fb09..ddffb97ab42 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -208,6 +208,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model ], "query": "option1, option2", "skipUrlSync": false, + "valuesFormat": "csv", }, }, { diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 8772646b496..63d73994624 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -390,6 +390,7 @@ describe('sceneVariablesSetToVariables', () => { ], "query": "test,test1,test2", "type": "custom", + "valuesFormat": "csv", } `); }); @@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => { ], "query": "test,test1,test2", "skipUrlSync": false, + "valuesFormat": "csv", }, } `); diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx index 924f9fa5702..34c06aea3ed 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx @@ -1,4 +1,5 @@ -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { selectors } from '@grafana/e2e-selectors'; @@ -130,4 +131,71 @@ describe('CustomVariableForm', () => { expect(onMultiChange).not.toHaveBeenCalled(); expect(onIncludeAllChange).not.toHaveBeenCalled(); }); + + describe('JSON values format', () => { + test('should render the form fields correctly', async () => { + const { getByTestId, queryByTestId } = render( + + ); + + await userEvent.click(screen.getByText('Object values in a JSON array')); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const allowCustomValueCheckbox = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).toBeChecked(); + + expect(allowCustomValueCheckbox).not.toBeInTheDocument(); + expect(allValueInput).not.toBeInTheDocument(); + }); + + test('should display validation error', async () => { + const validationError = new Error('Ooops! Validation error.'); + + const { findByText } = render( + + ); + + await userEvent.click(screen.getByText('Object values in a JSON array')); + + const errorEl = await findByText(validationError.message); + expect(errorEl).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/dashboard-scene/utils/variables.test.ts b/public/app/features/dashboard-scene/utils/variables.test.ts index e627bd01866..e8126023fc9 100644 --- a/public/app/features/dashboard-scene/utils/variables.test.ts +++ b/public/app/features/dashboard-scene/utils/variables.test.ts @@ -103,6 +103,7 @@ describe('when creating variables objects', () => { text: 'a', type: 'custom', value: 'a', + valuesFormat: 'csv', hide: 0, }); }); From 0400d536c73936335d871a7ac4e6895e07eb0398 Mon Sep 17 00:00:00 2001 From: grafakus Date: Wed, 19 Nov 2025 09:07:17 +0100 Subject: [PATCH 10/16] Fix K8s Codegen Check --- .../pkg/apis/dashboard/v2beta1/zz_generated.openapi.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index f833f1ddee0..9b0eb14b36b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -1510,6 +1510,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re Format: "", }, }, + "valuesFormat": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, }, From d6b04d28b65630bbe5431616e19afccf2c7ff24e Mon Sep 17 00:00:00 2001 From: grafakus Date: Mon, 24 Nov 2025 10:46:10 +0100 Subject: [PATCH 11/16] chore: Update to new Scenes version --- package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 0402c332903..f7356596757 100644 --- a/package.json +++ b/package.json @@ -296,8 +296,8 @@ "@grafana/plugin-ui": "^0.10.10", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.46.0", - "@grafana/scenes-react": "^6.46.0", + "@grafana/scenes": "^6.48.0", + "@grafana/scenes-react": "^6.48.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index c7ffd4339f4..ae099174311 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3596,11 +3596,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.46.0": - version: 6.46.0 - resolution: "@grafana/scenes-react@npm:6.46.0" +"@grafana/scenes-react@npm:^6.48.0": + version: 6.48.0 + resolution: "@grafana/scenes-react@npm:6.48.0" dependencies: - "@grafana/scenes": "npm:6.46.0" + "@grafana/scenes": "npm:6.48.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3612,7 +3612,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/be082f31c14e636efe6c60f98771a03b94606c6210a1f90d64a1c4ee8429ccb5aaa9e401092f4f6d728befeec13051d9811ec894bdee9b3c39865a5ebc2bba41 + checksum: 10/5afb2aa79271dd824cc35f0a59ec193ddcbd4e1f14e756551228ce218a19faad54923d3a83f8bbb10d38c1d23c49846df29e7fce8feba8ec9aec2d32d9c1cf8d languageName: node linkType: hard @@ -3642,9 +3642,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.46.0, @grafana/scenes@npm:^6.46.0": - version: 6.46.0 - resolution: "@grafana/scenes@npm:6.46.0" +"@grafana/scenes@npm:6.48.0, @grafana/scenes@npm:^6.48.0": + version: 6.48.0 + resolution: "@grafana/scenes@npm:6.48.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3664,7 +3664,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/c4b2b3113da0ea9b5745b4d560e73ad8877934138cb2862ed0fecf0954a36dc7aee2b15e12b802e0884ff8f56756935acc545019753f99a2900ba19a620a4e96 + checksum: 10/28cd64ea3c4faf87173ea71ffc136a7a525c33ec2e263ab2a98df718e3968ed7b7a12ecf0f309400af78e3c3269ae6048da8113412645a361a3e4925f9a2a810 languageName: node linkType: hard @@ -18885,8 +18885,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.10.10" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.46.0" - "@grafana/scenes-react": "npm:^6.46.0" + "@grafana/scenes": "npm:^6.48.0" + "@grafana/scenes-react": "npm:^6.48.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 18c4f5b875436928e6fa7172997819c569b909ce Mon Sep 17 00:00:00 2001 From: grafakus Date: Tue, 25 Nov 2025 12:06:47 +0100 Subject: [PATCH 12/16] feat: Update dynamic dashboards editors --- .../components/CustomVariableForm.tsx | 73 +++++++----- .../components/VariableValuesPreview.tsx | 13 ++- .../CustomVariableEditor.tsx | 7 +- .../CustomVariableEditor/ModalEditor.tsx | 84 +++++++++++-- .../getCustomVariableOptions.tsx | 2 - .../variables/editors/QueryVariableEditor.tsx | 110 ++++++++++++------ .../useVariableSelectionOptionsCategory.tsx | 9 +- 7 files changed, 212 insertions(+), 86 deletions(-) diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index 84f89df1c6a..b4ea09d190a 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -48,27 +48,7 @@ export function CustomVariableForm({ Custom options - - - {valuesFormat === 'json' && ( - - - - )} - + void; +} + +export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) { return ( - - Provide a JSON representing an array of objects, where each object can have any number of properties. -
- Check{' '} - - our docs - {' '} - for more information. -
+ + + {valuesFormat === 'json' && ( + + Provide a JSON representing an array of objects, where each object can have any number of properties. +
+ Check{' '} + + our docs + {' '} + for more information. + + } + placement="top" + interactive + > + +
+ )} +
); } diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx index 41a9d5c4fc3..f575363c956 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx @@ -36,7 +36,13 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt Preview of values - String(r.value)} pageSize={10} /> + String(r.value)} + pageSize={8} + />
); } @@ -97,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) { textOverflow: 'ellipsis', maxWidth: '50vw', }), + table: css({ + td: css({ + padding: theme.spacing(0.5, 1), + }), + }), }; } diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index 6c3774407f1..5c1f3158443 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -23,6 +23,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const onValuesFormatChange = useCallback( (format: CustomVariableModel['valuesFormat']) => { variable.setState({ query: prevQuery }); + variable.setState({ value: isMulti ? [] : undefined }); variable.setState({ valuesFormat: format }); variable.setState({ allowCustomValue: false }); variable.setState({ allValue: undefined }); @@ -33,7 +34,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi setPrevQuery(query); } }, - [onRunQuery, prevQuery, query, variable] + [isMulti, onRunQuery, prevQuery, query, variable] ); const onMultiChange = useCallback( @@ -52,6 +53,8 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const onQueryChange = useCallback( (event: FormEvent) => { + setPrevQuery(''); + if (valuesFormat === 'json') { const validationError = validateJsonQuery(event.currentTarget.value); setQueryValidationError(validationError); @@ -113,7 +116,7 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt ]; } -const validateJsonQuery = (rawQuery: string): Error | undefined => { +export const validateJsonQuery = (rawQuery: string): Error | undefined => { const query = rawQuery.trim(); if (!query) { return; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx index 3e8a8aa57b1..fa6dd46680a 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx @@ -1,15 +1,16 @@ -import { useCallback, useRef } from 'react'; +import { FormEvent, useCallback, useState } from 'react'; +import { lastValueFrom } from 'rxjs'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { CustomVariable } from '@grafana/scenes'; -import { Button, Modal, Stack } from '@grafana/ui'; +import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui'; -import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm'; -import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton'; +import { ValuesFormatSelector } from '../../components/CustomVariableForm'; +import { VariableValuesPreview } from '../../components/VariableValuesPreview'; -import { ValuesBuilder } from './ValuesBuilder'; -import { ValuesPreview } from './ValuesPreview'; +import { validateJsonQuery } from './CustomVariableEditor'; interface ModalEditorProps { variable: CustomVariable; @@ -18,9 +19,49 @@ interface ModalEditorProps { } export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) { - const formRef = useRef(null); + const { query, valuesFormat, isMulti } = variable.useState(); + const [prevQuery, setPrevQuery] = useState(''); + const [queryValidationError, setQueryValidationError] = useState(); - const handleOnAdd = useCallback(() => formRef.current?.addItem(), []); + const onValuesFormatChange = useCallback( + async (format: CustomVariableModel['valuesFormat']) => { + variable.setState({ query: prevQuery }); + variable.setState({ value: isMulti ? [] : undefined }); + variable.setState({ valuesFormat: format }); + variable.setState({ allowCustomValue: false }); + variable.setState({ allValue: undefined }); + + await lastValueFrom(variable.validateAndUpdate()); + + setQueryValidationError(undefined); + if (query !== prevQuery) { + setPrevQuery(query); + } + }, + [isMulti, prevQuery, query, variable] + ); + + const onQueryChange = useCallback( + async (event: FormEvent) => { + setPrevQuery(''); + + if (valuesFormat === 'json') { + const validationError = validateJsonQuery(event.currentTarget.value); + setQueryValidationError(validationError); + if (validationError) { + return; + } + } + + variable.setState({ query: event.currentTarget.value }); + await lastValueFrom(variable.validateAndUpdate()); + }, + [valuesFormat, variable] + ); + + const optionsForSelect = variable.getOptionsForSelect(false); + const hasJsonValuesFormat = variable.state.valuesFormat === 'json'; + const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties)); return ( - - + +
+