From 198f4dbf93980353565b69ca6ddb347e00ae32bd Mon Sep 17 00:00:00 2001 From: grafakus Date: Thu, 13 Nov 2025 14:08:00 +0100 Subject: [PATCH] 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, }),