CustomVariable: support values with multiple properties (json values format) (#113844)

* update Scenes libraries

---------

Co-authored-by: idastambuk <ida.stambuk@grafana.com>
This commit is contained in:
Marc M.
2026-01-07 11:01:03 +01:00
committed by GitHub
co-authored by idastambuk
parent 05681efee3
commit be734e970e
48 changed files with 1480 additions and 450 deletions
@@ -129,7 +129,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"
@@ -932,6 +932,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -935,6 +935,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -222,8 +222,10 @@ lineage: schemas: [{
// Optional field, 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.
regex?: string
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
valuesFormat?: "csv" | "json" | *"csv"
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
@@ -222,8 +222,10 @@ lineage: schemas: [{
// Optional field, 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.
regex?: string
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
valuesFormat?: "csv" | "json" | *"csv"
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
@@ -133,7 +133,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"
@@ -936,6 +936,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -1703,18 +1703,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"`
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.
@@ -2098,6 +2099,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"`
@@ -1548,6 +1548,12 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref common.R
Format: "",
},
},
"valuesFormat": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
},
@@ -939,6 +939,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -1707,18 +1707,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"`
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.
@@ -2133,6 +2134,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"`
@@ -1560,6 +1560,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"},
},
File diff suppressed because one or more lines are too long
@@ -1336,6 +1336,17 @@ func buildCustomVariable(varMap map[string]interface{}, commonProps CommonVariab
customVar.Spec.AllValue = &allValue
}
if valuesFormat := schemaversion.GetStringValue(varMap, "valuesFormat"); valuesFormat != "" {
switch valuesFormat {
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson):
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson
customVar.Spec.ValuesFormat = &format
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv):
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv
customVar.Spec.ValuesFormat = &format
}
}
return dashv2alpha1.DashboardVariableKind{
CustomVariableKind: customVar,
}, nil
@@ -685,6 +685,7 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync,
Description: in.CustomVariableKind.Spec.Description,
AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue,
ValuesFormat: convertCustomValuesFormat_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.ValuesFormat),
},
}
}
@@ -758,6 +759,23 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
return nil
}
func convertCustomValuesFormat_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardCustomVariableSpecValuesFormat) *dashv2beta1.DashboardCustomVariableSpecValuesFormat {
if in == nil {
return nil
}
switch *in {
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson:
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatJson
return &v
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv:
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatCsv
return &v
default:
return nil
}
}
func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error {
out.Name = in.Name
out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current)
+4 -2
View File
@@ -218,8 +218,10 @@ lineage: schemas: [{
// Optional field, 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.
regex?: string
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
valuesFormat?: "csv" | "json" | *"csv"
// Determine whether regex applies to variable value or display text
regexApplyTo?: #VariableRegexApplyTo
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
+2 -2
View File
@@ -295,8 +295,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.52.0",
"@grafana/scenes-react": "6.52.0",
"@grafana/scenes": "v6.52.1",
"@grafana/scenes-react": "v6.52.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -103,6 +103,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
export interface CustomVariableModel extends VariableWithMultiSupport {
type: 'custom';
valuesFormat?: 'csv' | 'json';
}
export interface DataSourceVariableModel extends VariableWithMultiSupport {
@@ -211,6 +211,10 @@ export interface VariableModel {
* Type of variable
*/
type: VariableType;
/**
* Optional, indicates whether a custom type variable uses CSV or JSON to define its values
*/
valuesFormat?: ('csv' | 'json');
}
export const defaultVariableModel: Partial<VariableModel> = {
@@ -220,6 +224,7 @@ export const defaultVariableModel: Partial<VariableModel> = {
options: [],
skipUrlSync: false,
staticOptions: [],
valuesFormat: 'csv',
};
/**
@@ -317,6 +317,7 @@ export const handyTestingSchema: Spec = {
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
{
@@ -300,7 +300,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@@ -1353,6 +1353,7 @@ export interface CustomVariableSpec {
skipUrlSync: boolean;
description?: string;
allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
}
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
@@ -1365,6 +1366,7 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
hide: "dontHide",
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: undefined,
});
// Group variable kind
@@ -1549,4 +1551,3 @@ export const defaultSpec = (): Spec => ({
title: "",
variables: [],
});
@@ -1359,6 +1359,7 @@ export interface CustomVariableSpec {
skipUrlSync: boolean;
description?: string;
allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
}
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
+10
View File
@@ -837,6 +837,8 @@ type VariableModel struct {
// Optional field, 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.
Regex *string `json:"regex,omitempty"`
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
ValuesFormat *VariableModelValuesFormat `json:"valuesFormat,omitempty"`
// Determine whether regex applies to variable value or display text
RegexApplyTo *VariableRegexApplyTo `json:"regexApplyTo,omitempty"`
// Additional static options for query variable
@@ -852,6 +854,7 @@ func NewVariableModel() *VariableModel {
Multi: (func(input bool) *bool { return &input })(false),
AllowCustomValue: (func(input bool) *bool { return &input })(true),
IncludeAll: (func(input bool) *bool { return &input })(false),
ValuesFormat: (func(input VariableModelValuesFormat) *VariableModelValuesFormat { return &input })(VariableModelValuesFormatCsv),
}
}
@@ -1191,6 +1194,13 @@ const (
DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates"
)
type VariableModelValuesFormat string
const (
VariableModelValuesFormatCsv VariableModelValuesFormat = "csv"
VariableModelValuesFormatJson VariableModelValuesFormat = "json"
)
type VariableModelStaticOptionsOrder string
const (
@@ -1786,6 +1786,13 @@
"skipUrlSync": {
"type": "boolean",
"default": false
},
"valuesFormat": {
"enum": [
"csv",
"json"
],
"type": "string"
}
},
"additionalProperties": false
@@ -1801,6 +1801,13 @@
"skipUrlSync": {
"type": "boolean",
"default": false
},
"valuesFormat": {
"type": "string",
"enum": [
"csv",
"json"
]
}
},
"additionalProperties": false
@@ -345,6 +345,16 @@ describe('DashboardSceneSerializer', () => {
type: 'textbox',
name: 'search',
},
{
name: 'custom_csv',
type: 'custom',
valuesFormat: 'csv',
},
{
name: 'custom_json',
type: 'custom',
valuesFormat: 'json',
},
],
},
});
@@ -359,6 +369,9 @@ describe('DashboardSceneSerializer', () => {
panel_type_row_count: 1,
variable_type_query_count: 2,
variable_type_textbox_count: 1,
variable_type_custom_count: 2,
variable_type_custom_csv_count: 1,
variable_type_custom_json_count: 1,
settings_nowdelay: undefined,
settings_livenow: true,
varsWithDataSource: [
@@ -701,7 +714,9 @@ describe('DashboardSceneSerializer', () => {
panel_type_timeseries_count: 6,
variable_type_adhoc_count: 1,
variable_type_datasource_count: 1,
variable_type_custom_count: 1,
variable_type_custom_count: 3,
variable_type_custom_csv_count: 2,
variable_type_custom_json_count: 1,
variable_type_query_count: 1,
varsWithDataSource: [
{ type: 'query', datasource: 'cloudwatch' },
@@ -714,7 +729,7 @@ describe('DashboardSceneSerializer', () => {
panelCount: 6,
rowCount: 6,
tabCount: 4,
templateVariableCount: 4,
templateVariableCount: 6,
maxNestingLevel: 3,
dashStructure:
'[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]',
@@ -866,6 +881,7 @@ describe('DashboardSceneSerializer', () => {
query: 'app1',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
]);
@@ -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": {
@@ -680,6 +681,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": {
@@ -698,6 +700,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",
},
],
},
@@ -1021,6 +1024,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
"options": [],
"query": "a, b, c",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -1381,6 +1385,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
"options": [],
"query": "a, b, c",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -196,6 +196,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
"options": [],
"query": "option1, option2",
"skipUrlSync": false,
"valuesFormat": "csv",
},
},
{
@@ -376,6 +376,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [],
"query": "test,test1,test2",
"type": "custom",
"valuesFormat": "csv",
}
`);
});
@@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [],
"query": "test,test1,test2",
"skipUrlSync": false,
"valuesFormat": "csv",
},
}
`);
@@ -120,6 +120,9 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
allValue: variable.state.allValue,
includeAll: variable.state.includeAll,
...(variable.state.allowCustomValue !== undefined && { allowCustomValue: variable.state.allowCustomValue }),
// Ensure we persist the backend default when not specified to stay aligned with
// transformSaveModelSchemaV2ToScene which injects 'csv' on load.
valuesFormat: variable.state.valuesFormat ?? 'csv',
};
variables.push(customVariable);
} else if (sceneUtils.isDataSourceVariable(variable)) {
@@ -408,6 +411,7 @@ export function sceneVariablesSetToSchemaV2Variables(
allValue: variable.state.allValue,
includeAll: variable.state.includeAll ?? false,
allowCustomValue: variable.state.allowCustomValue ?? true,
valuesFormat: variable.state.valuesFormat ?? 'csv',
},
};
variables.push(customVariable);
@@ -1169,6 +1169,57 @@
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": "test",
"value": "test"
},
"hide": "dontHide",
"includeAll": false,
"multi": false,
"name": "custom0",
"options": [
{
"selected": true,
"text": "test",
"value": "test"
}
],
"valuesFormat": "csv",
"query": "test",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": "test",
"value": "test"
},
"hide": "dontHide",
"includeAll": false,
"multi": false,
"name": "custom0",
"options": [
{
"selected": true,
"text": "test",
"value": "test",
"properties": {
"testProp": "test"
}
}
],
"valuesFormat": "json",
"query": "test",
"skipUrlSync": false
}
},
{
"kind": "DatasourceVariable",
"spec": {
@@ -343,12 +343,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
}
return new AdHocFiltersVariable(adhocVariableState);
}
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,
@@ -357,6 +357,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
...(variable.spec.allowCustomValue !== undefined && { allowCustomValue: variable.spec.allowCustomValue }),
valuesFormat: variable.spec.valuesFormat || 'csv',
});
} else if (variable.kind === defaultQueryVariableKind().kind) {
return new QueryVariable({
@@ -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 { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
@@ -68,6 +68,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display });
const isHasVariableOptions = hasVariableOptions(variable);
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable');
@@ -123,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
<div className={styles.buttonContainer}>
<Stack gap={2}>
@@ -1,9 +1,16 @@
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';
import { CustomVariableForm } from './CustomVariableForm';
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
actual.config.featureToggles = { multiPropsVariables: true };
return actual;
});
describe('CustomVariableForm', () => {
const onQueryChange = jest.fn();
const onMultiChange = jest.fn();
@@ -130,4 +137,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(
<CustomVariableForm
query="query"
valuesFormat="json"
multi={true}
allowCustomValue={true}
includeAll={true}
allValue="custom value"
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
await userEvent.click(screen.getByText('JSON'));
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(
<CustomVariableForm
query="query"
valuesFormat="json"
queryValidationError={validationError}
multi={false}
includeAll={false}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
await userEvent.click(screen.getByText('JSON'));
const errorEl = await findByText(validationError.message);
expect(errorEl).toBeInTheDocument();
});
});
});
@@ -1,7 +1,10 @@
import { FormEvent } from 'react';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
import { SelectionOptionsForm } from './SelectionOptionsForm';
import { VariableLegend } from './VariableLegend';
@@ -9,10 +12,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
interface CustomVariableFormProps {
query: string;
valuesFormat?: CustomVariableModel['valuesFormat'];
multi: boolean;
allValue?: string | null;
includeAll: boolean;
allowCustomValue?: boolean;
queryValidationError?: Error;
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
@@ -20,9 +25,137 @@ interface CustomVariableFormProps {
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
}
export function CustomVariableForm({
query,
valuesFormat,
multi,
allValue,
includeAll,
allowCustomValue,
queryValidationError,
onQueryChange,
onMultiChange,
onIncludeAllChange,
onAllValueChange,
onAllowCustomValueChange,
onValuesFormatChange,
}: CustomVariableFormProps) {
if (!config.featureToggles.multiPropsVariables) {
return (
<CustomVariableFormNonMultiProps
displayMultiPropsWarningBanner={valuesFormat === 'json'}
query={query}
multi={multi}
allValue={allValue}
includeAll={includeAll}
allowCustomValue={allowCustomValue}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onAllValueChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
}
return (
<>
<VariableLegend>
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
</VariableLegend>
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<VariableTextAreaField
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
key={valuesFormat}
name=""
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
defaultValue={query}
onBlur={onQueryChange}
required
width={52}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
<VariableLegend>
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
</VariableLegend>
<SelectionOptionsForm
multi={multi}
includeAll={includeAll}
allValue={allValue}
allowCustomValue={allowCustomValue}
disableAllowCustomValue={valuesFormat === 'json'}
disableCustomAllValue={valuesFormat === 'json'}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
</>
);
}
interface ValuesFormatSelectorProps {
valuesFormat?: CustomVariableModel['valuesFormat'];
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
}
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
return (
<Stack direction="row" gap={1}>
<RadioButtonGroup
value={valuesFormat}
onChange={onValuesFormatChange}
options={[
{
value: 'csv',
label: config.featureToggles.multiPropsVariables
? t('dashboard-scene.custom-variable-form.name-csv-values', 'CSV')
: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
},
{
value: 'json',
label: t('dashboard-scene.custom-variable-form.name-json-values', 'JSON'),
},
]}
/>
{valuesFormat === 'json' && (
<Tooltip
content={
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
Provide a JSON representing an array of objects, where each object can have any number of properties.
<br />
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
placement="top"
interactive
>
<Icon name="info-circle" />
</Tooltip>
)}
</Stack>
);
}
function CustomVariableFormNonMultiProps({
displayMultiPropsWarningBanner,
query,
multi,
allValue,
@@ -33,13 +166,23 @@ export function CustomVariableForm({
onIncludeAllChange,
onAllValueChange,
onAllowCustomValueChange,
}: CustomVariableFormProps) {
}: CustomVariableFormProps & { displayMultiPropsWarningBanner: boolean }) {
return (
<>
<VariableLegend>
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
</VariableLegend>
{displayMultiPropsWarningBanner && (
<div style={{ maxWidth: '25%' }}>
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
multi-properties.
</Alert>
</div>
)}
<VariableTextAreaField
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
defaultValue={query}
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
multi: boolean;
includeAll: boolean;
allowCustomValue?: boolean;
disableAllowCustomValue?: boolean;
allValue?: string | null;
disableCustomAllValue?: boolean;
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => 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
<VariableCheckboxField
value={allowCustomValue ?? true}
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
description={t(
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
'Enables users to add custom values to the list'
)}
onChange={onAllowCustomValueChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
/>
)}
{!disableAllowCustomValue &&
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
<VariableCheckboxField
value={allowCustomValue ?? true}
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
description={t(
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
'Enables users to add custom values to the list'
)}
onChange={onAllowCustomValueChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
/>
)}
<VariableCheckboxField
value={includeAll}
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
onChange={onIncludeAllChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
/>
{includeAll && (
{!disableCustomAllValue && includeAll && (
<VariableTextField
defaultValue={allValue ?? ''}
onBlur={onAllValueChange}
@@ -1,17 +1,94 @@
import { css } from '@emotion/css';
import { MouseEvent, useCallback, useEffect, useState } from 'react';
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
export interface VariableValuesPreviewProps {
export interface Props {
options: VariableValueOption[];
hasMultiProps?: boolean;
}
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
const styles = useStyles2(getStyles);
const hasOptions = options.length > 0;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values" values={{ count: options.length }}>
Preview of values ({'{{count}}'})
</Trans>
{hasOptions && displayMultiPropsPreview && <VariableValuesWithPropsPreview options={options} />}
{hasOptions && !displayMultiPropsPreview && <VariableValuesWithoutPropsPreview options={options} />}
</Text>
</div>
);
};
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const { data, columns } = useMemo(() => {
const data = options.map(({ label, value, properties }) => ({
label: String(label),
value: String(value),
...flattenProperties(properties),
}));
return {
data,
columns: Object.keys(data[0] ?? {}).map((id) => ({
id,
// see https://github.com/TanStack/table/issues/1671
header: unsanitizeKey(id),
sortType: 'alphanumeric' as const,
})),
};
}, [options]);
return (
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
);
}
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record<string, string> {
if (properties === undefined) {
return {};
}
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(properties)) {
const newPath = path ? `${path}.${key}` : key;
if (typeof value === 'object') {
Object.assign(result, flattenProperties(value, newPath));
} else {
// see https://github.com/TanStack/table/issues/1671
result[sanitizeKey(newPath)] = value;
}
}
return result;
}
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const [previewLimit, setPreviewLimit] = useState(20);
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
const showMoreOptions = useCallback(
@@ -21,18 +98,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 (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
<>
<InlineFieldRow>
{previewOptions.map((o, index) => (
<InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}>
@@ -49,16 +118,17 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
</Button>
</InlineFieldRow>
)}
</div>
</>
);
};
VariableValuesPreview.displayName = 'VariableValuesPreview';
}
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
previewContainer: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
marginTop: theme.spacing(2),
}),
optionContainer: css({
@@ -71,5 +141,10 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis',
maxWidth: '50vw',
}),
table: css({
td: css({
padding: theme.spacing(0.5, 1),
}),
}),
};
}
@@ -5,117 +5,231 @@ import { CustomVariable } from '@grafana/scenes';
import { CustomVariableEditor } from './CustomVariableEditor';
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
actual.config.featureToggles = { multiPropsVariables: true };
return actual;
});
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
return {
variable: new CustomVariable({
name: 'customVar',
...options,
}),
onRunQuery: jest.fn(),
};
}
function renderEditor(ui: React.ReactNode) {
const renderResult = render(ui);
const elements = {
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
queryInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLTextAreaElement,
multiValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement,
allowCustomValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
) as HTMLInputElement,
includeAllCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement,
customAllValueInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement,
};
return {
...renderResult,
elements,
actions: {
updateValuesInput(newQuery: string) {
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
fireEvent.blur(elements.queryInput());
},
changeValuesFormat(newFormat: 'csv' | 'json') {
const targetLabel = newFormat === 'json' ? 'JSON' : 'CSV';
const formatButton = elements.formatButton(targetLabel);
if (formatButton === null) {
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
}
fireEvent.click(formatButton);
},
},
};
}
describe('CustomVariableEditor', () => {
it('should render the CustomVariableForm with correct initial values', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allValue: 'test',
describe('CSV values format', () => {
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: 'all',
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe('test, test2');
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput().value).toBe('all');
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: false,
allowCustomValue: false,
includeAll: false,
});
const queryInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLInputElement;
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement;
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement;
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(queryInput.value).toBe('test, test2');
expect(allValueInput.value).toBe('test');
expect(multiCheckbox.checked).toBe(true);
expect(includeAllCheckbox.checked).toBe(true);
expect(elements.multiValueCheckbox().checked).toBe(false);
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
expect(elements.includeAllCheckbox().checked).toBe(false);
// include-all-custom input appears after include-all checkbox is checked only
expect(elements.customAllValueInput()).not.toBeInTheDocument();
fireEvent.click(elements.multiValueCheckbox());
fireEvent.click(elements.allowCustomValueCheckbox());
fireEvent.click(elements.includeAllCheckbox());
expect(variable.state.isMulti).toBe(true);
expect(variable.state.allowCustomValue).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(elements.customAllValueInput()).toBeInTheDocument();
});
describe('when the values textarea loses focus after its value has changed', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
actions.updateValuesInput('test3, test4');
expect(variable.state.query).toBe('test3, test4');
expect(onRunQuery).toHaveBeenCalled();
});
});
describe('when the "Custom all value" input loses focus after its value has changed', () => {
it('should update the variable state', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
fireEvent.blur(elements.customAllValueInput());
expect(variable.state.allValue).toBe('new custom all');
});
});
});
it('should update the variable state when input values change', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('JSON values format', () => {
const initialJsonQuery = `[
{"value":1,"text":"Development","aws":"dev","azure":"development"},
{"value":2,"text":"Production","aws":"prod","azure":"production"}
]`;
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'json',
query: initialJsonQuery,
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe(initialJsonQuery);
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput()).not.toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
describe('when the values textarea loses focus after its value has changed', () => {
describe('if the value is valid JSON', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allowCustomValueCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
actions.updateValuesInput('[]');
// It include-all-custom input appears after include-all checkbox is checked only
expect(() =>
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
).toThrow('Unable to find an element');
expect(variable.state.query).toBe('[]');
expect(onRunQuery).toHaveBeenCalled();
});
});
fireEvent.click(allowCustomValueCheckbox);
describe('if the value is NOT valid JSON', () => {
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
fireEvent.click(multiCheckbox);
const { actions, getByRole } = renderEditor(
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
);
fireEvent.click(includeAllCheckbox);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
);
actions.updateValuesInput('[x]');
expect(variable.state.isMulti).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(variable.state.allowCustomValue).toBe(false);
expect(allValueInput).toBeInTheDocument();
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
expect(variable.state.query).toBe(initialJsonQuery);
expect(onRunQuery).not.toHaveBeenCalled();
});
});
});
});
it('should call update query and re-run query when input loses focus', async () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('when switching values format', () => {
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'csv',
query: '',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: '',
});
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
actions.changeValuesFormat('json');
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.customAllValueInput()).not.toBeInTheDocument();
actions.changeValuesFormat('csv');
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
fireEvent.blur(queryInput);
expect(onRunQuery).toHaveBeenCalled();
expect(variable.state.query).toBe('test3, test4');
});
it('should update the variable state when all-custom-value input loses focus', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
fireEvent.blur(allValueInput);
expect(variable.state.allValue).toBe('new custom all');
});
});
@@ -1,16 +1,42 @@
import { FormEvent, useCallback } from 'react';
import { isObject } from 'lodash';
import { FormEvent, useCallback, useState } from 'react';
import { CustomVariable } from '@grafana/scenes';
import { CustomVariableModel, shallowCompare } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { CustomVariableForm } from '../../components/CustomVariableForm';
import { PaneItem } from './PaneItem';
interface CustomVariableEditorProps {
variable: CustomVariable;
onRunQuery: () => void;
}
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const [queryValidationError, setQueryValidationError] = useState<Error>();
const [prevQuery, setPrevQuery] = useState('');
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 });
onRunQuery();
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, onRunQuery, prevQuery, query, variable]
);
const onMultiChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
@@ -28,10 +54,24 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (config.featureToggles.multiPropsVariables && valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value.trim());
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
if (!config.featureToggles.multiPropsVariables) {
variable.setState({ valuesFormat: 'csv' });
}
variable.setState({ query: event.currentTarget.value });
onRunQuery();
},
[variable, onRunQuery]
[valuesFormat, variable, onRunQuery]
);
const onAllValueChange = useCallback(
@@ -51,15 +91,75 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
return (
<CustomVariableForm
query={query ?? ''}
valuesFormat={valuesFormat ?? 'csv'}
multi={!!isMulti}
allValue={allValue ?? ''}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
queryValidationError={queryValidationError}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
onValuesFormatChange={onValuesFormatChange}
/>
);
}
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
if (!(variable instanceof CustomVariable)) {
return [];
}
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
];
}
export const validateJsonQuery = (query: string): Error | undefined => {
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;
}
};
@@ -1,23 +1,43 @@
import { useRef, useState } from 'react';
import { FormEvent, useMemo, useRef, 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, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
import { Button, Modal, Stack } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { CustomVariable } from '@grafana/scenes';
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
import { dashboardEditActions } from '../../../../edit-pane/shared';
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { validateJsonQuery } from './CustomVariableEditor';
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
interface ModalEditorProps {
variable: CustomVariable;
onClose: () => void;
}
export function ModalEditor(props: ModalEditorProps) {
const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props);
if (!config.featureToggles.multiPropsVariables) {
return <ModalEditorNonMultiProps {...props} />;
}
return <ModalEditorMultiProps {...props} />;
}
function ModalEditorMultiProps(props: ModalEditorProps) {
const {
valuesFormat,
query,
queryValidationError,
options,
onCloseModal,
onValuesFormatChange,
onQueryChange,
onSaveOptions,
} = useModalEditor(props);
return (
<Modal
@@ -28,10 +48,31 @@ export function ModalEditor(props: ModalEditorProps) {
closeOnEscape={false}
>
<Stack direction="column" gap={2}>
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
<VariableValuesPreview options={options} />
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<div>
<TextArea
id={valuesFormat}
key={valuesFormat}
rows={4}
defaultValue={query}
onChange={onQueryChange}
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
required
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
</div>
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
<Modal.ButtonRow>
<Button
variant="secondary"
fill="outline"
@@ -43,6 +84,7 @@ export function ModalEditor(props: ModalEditorProps) {
<Button
variant="primary"
onClick={onSaveOptions}
disabled={Boolean(queryValidationError)}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
>
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
@@ -53,51 +95,82 @@ export function ModalEditor(props: ModalEditorProps) {
}
function useModalEditor({ variable, onClose }: ModalEditorProps) {
const { query } = variable.state;
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
const initialQueryRef = useRef(query);
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
const initialValuesFormatRef = useRef(variable.state.valuesFormat);
const initialQueryRef = useRef(variable.state.query);
const [valuesFormat, setValuesFormat] = useState(() => variable.state.valuesFormat);
const [query, setQuery] = useState(() => variable.state.query);
const [prevQuery, setPrevQuery] = useState('');
const [queryValidationError, setQueryValidationError] = useState<Error>();
const options = useMemo(() => {
if (valuesFormat === 'csv') {
return variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
value,
label: value === label ? '' : label,
}));
} else {
return variable.transformJsonToOptions(query);
}
}, [query, valuesFormat, variable]);
return {
formRef,
onCloseModal: onClose,
valuesFormat,
query,
queryValidationError,
options,
onChangeOptions: setOptions,
onAddNewOption() {
formRef.current?.addItem();
onCloseModal: onClose,
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
setQuery(prevQuery);
setValuesFormat(newFormat);
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
onQueryChange(event: FormEvent<HTMLTextAreaElement>) {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
setQuery(event.currentTarget.value);
},
onSaveOptions() {
dashboardEditActions.edit({
source: variable,
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
perform: () => {
variable.setState({ query: transformOptionsToQuery(options) });
lastValueFrom(variable.validateAndUpdate!());
description: t('dashboard-scene.use-modal-editor.description.change-variable-query', 'Change variable query'),
perform: async () => {
if (!config.featureToggles.multiPropsVariables) {
variable.setState({ valuesFormat: 'csv', query, value: undefined });
} else {
variable.setState({ valuesFormat, query, value: undefined });
}
if (valuesFormat === 'json') {
variable.setState({ allowCustomValue: false, allValue: undefined });
}
await lastValueFrom(variable.validateAndUpdate!());
},
undo: () => {
variable.setState({ query: initialQueryRef.current });
lastValueFrom(variable.validateAndUpdate!());
undo: async () => {
variable.setState({
valuesFormat: initialValuesFormatRef.current,
query: initialQueryRef.current,
value: undefined,
});
if (initialValuesFormatRef.current === 'json') {
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
}
await lastValueFrom(variable.validateAndUpdate!());
},
});
onClose();
},
};
}
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
value,
label: value === label ? '' : label,
}));
const formatOption = (option: VariableValueOption) => {
if (!option.label || option.label === option.value) {
return escapeEntities(option.value);
}
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
};
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
@@ -0,0 +1,119 @@
import { useRef, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
import { Alert, Button, Modal, Stack } from '@grafana/ui';
import { dashboardEditActions } from '../../../../edit-pane/shared';
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
interface ModalEditorProps {
variable: CustomVariable;
onClose: () => void;
}
export function ModalEditorNonMultiProps(props: ModalEditorProps) {
const {
displayMultiPropsWarningBanner,
formRef,
onCloseModal,
options,
onChangeOptions,
onAddNewOption,
onSaveOptions,
} = useModalEditor(props);
return (
<Modal
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
isOpen={true}
onDismiss={onCloseModal}
closeOnBackdropClick={false}
closeOnEscape={false}
>
{displayMultiPropsWarningBanner && (
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
multi-properties.
</Alert>
)}
<Stack direction="column" gap={2}>
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
<VariableValuesPreview options={options} />
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
<Button
variant="secondary"
fill="outline"
onClick={onCloseModal}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton}
>
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.discard">Discard</Trans>
</Button>
<Button
variant="primary"
onClick={onSaveOptions}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
>
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
);
}
function useModalEditor({ variable, onClose }: ModalEditorProps) {
const { query, valuesFormat } = variable.state;
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
const initialQueryRef = useRef(query);
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
return {
displayMultiPropsWarningBanner: valuesFormat === 'json',
formRef,
onCloseModal: onClose,
options,
onChangeOptions: setOptions,
onAddNewOption() {
formRef.current?.addItem();
},
onSaveOptions() {
dashboardEditActions.edit({
source: variable,
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
perform: () => {
variable.setState({ query: transformOptionsToQuery(options) });
lastValueFrom(variable.validateAndUpdate!());
},
undo: () => {
variable.setState({ query: initialQueryRef.current });
lastValueFrom(variable.validateAndUpdate!());
},
});
onClose();
},
};
}
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
value,
label: value === label ? '' : label,
}));
const formatOption = (option: VariableValueOption) => {
if (!option.label || option.label === option.value) {
return escapeEntities(option.value);
}
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
};
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
@@ -1,11 +1,19 @@
import { useCallback, useId, useMemo, useRef } from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { MultiValueVariable, SceneVariableValueChangedEvent } from '@grafana/scenes';
import { Input, Switch } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
function useVariableHasMultiProps(variable: MultiValueVariable) {
const state = variable.useState();
const hasMultiProps =
config.featureToggles.multiPropsVariables && 'valuesFormat' in state && state.valuesFormat === 'json';
return hasMultiProps;
}
export function useVariableSelectionOptionsCategory(variable: MultiValueVariable): OptionsPaneCategoryDescriptor {
const multiValueId = useId();
const includeAllId = useId();
@@ -45,7 +53,9 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'A wildcard regex or other value to represent All'
),
useShowIf: () => {
return variable.useState().includeAll ?? false;
const state = variable.useState();
const hasMultiProps = useVariableHasMultiProps(variable);
return hasMultiProps ? false : (state.includeAll ?? false);
},
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
})
@@ -58,6 +68,10 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values'
),
useShowIf: () => {
const hasMultiProps = useVariableHasMultiProps(variable);
return !hasMultiProps;
},
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
})
);
@@ -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,
}),
@@ -77,7 +77,7 @@ describe('dashboard tracking', () => {
isScene: true,
tabCount: 4,
rowCount: 2,
templateVariableCount: 4,
templateVariableCount: 6,
maxNestingLevel: 3,
panel_type_timeseries_count: 6,
panels_count: 6,
@@ -92,7 +92,9 @@ describe('dashboard tracking', () => {
theme: undefined,
title: 'Cloudwatch ec2 new layout',
uid: 'dashboard-test',
variable_type_custom_count: 1,
variable_type_custom_count: 3,
variable_type_custom_csv_count: 2,
variable_type_custom_json_count: 1,
variable_type_query_count: 1,
variable_type_datasource_count: 1,
variable_type_adhoc_count: 1,
@@ -184,6 +184,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
skipUrlSync: variable.skipUrlSync,
hide: variable.hide,
allowCustomValue: variable.allowCustomValue,
valuesFormat: variable.valuesFormat ?? 'csv',
});
// Query variable
} else if (variable.type === 'query') {
@@ -1,6 +1,7 @@
import { VariableModel } from '@grafana/schema/dist/esm/index';
import {
AdhocVariableKind,
CustomVariableKind,
DatasourceVariableKind,
QueryVariableKind,
VariableKind,
@@ -49,6 +50,11 @@ export function getV1SchemaVariables(variableList: VariableModel[]) {
return {
// Count variable types
...variableList.reduce<Record<string, number>>((variables, current) => {
// track how many custom CSV and custom JSON variables are used in a dashboard
if (current.type === 'custom') {
const name = variableName(`custom_${current.valuesFormat || 'csv'}`);
variables[name] = 1 + (variables[name] || 0);
}
variables[variableName(current.type)] = 1 + (variables[variableName(current.type)] || 0);
return variables;
}, {}),
@@ -94,6 +100,11 @@ export function getV2SchemaVariables(variableList: VariableKind[]) {
...variableList.reduce<Record<string, number>>((variables, current) => {
const type = mapNewToOldTypes(current.kind);
if (type) {
// track how many custom CSV and custom JSON variables are used in a dashboard
if (isCustomVariable(current)) {
const name = variableName(`custom_${current.spec.valuesFormat || 'csv'}`);
variables[name] = 1 + (variables[name] || 0);
}
variables[variableName(type)] = 1 + (variables[variableName(type)] || 0);
}
return variables;
@@ -120,3 +131,7 @@ const isQueryVar: (v: VariableKind) => v is QueryVariableKind = (v) => v.kind ==
const getDatasourceFromVar = (v: VariableKind) =>
isAdhocVar(v) ? v.group : isDatasourceVar(v) ? v.spec.pluginId : isQueryVar(v) ? v.spec?.query.group : '';
export function isCustomVariable(variable: VariableKind): variable is CustomVariableKind {
return variable.kind === 'CustomVariable';
}
@@ -14,6 +14,7 @@ import {
locationUtil,
ScopedVars,
textUtil,
TypedVariableModel,
urlUtil,
VariableOrigin,
VariableSuggestion,
@@ -79,14 +80,48 @@ const buildLabelPath = (label: string) => {
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
};
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
return [];
}
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
let paths: string[] = [];
for (const field in option) {
if (option.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`;
const value = option[field];
if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
paths.push(newPath);
}
}
return paths;
}
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
...getTemplateSrv()
.getVariables()
.map((variable) => ({
value: variable.name,
label: variable.name,
origin: VariableOrigin.Template,
})),
.flatMap((variable) => [
{
value: variable.name,
label: variable.name,
origin: VariableOrigin.Template,
},
...getVariableValueProperties(variable).map((fieldPath) => ({
value: fieldPath,
label: fieldPath,
origin: VariableOrigin.Template,
})),
]),
{
value: `${DataLinkBuiltInVars.includeVars}`,
label: t('panel.get-panel-links-variable-suggestions.label.all-variables', 'All variables'),
@@ -1,4 +1,11 @@
import { FieldType, GrafanaConfig, locationUtil, toDataFrame, VariableOrigin } from '@grafana/data';
import {
DataLinkBuiltInVars,
FieldType,
GrafanaConfig,
locationUtil,
toDataFrame,
VariableOrigin,
} from '@grafana/data';
import { setTemplateSrv } from '@grafana/runtime';
import { DashboardLink } from '@grafana/schema';
import { ContextSrv } from 'app/core/services/context_srv';
@@ -10,7 +17,7 @@ import { createQueryVariableAdapter } from 'app/features/variables/query/adapter
import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv';
import { updateConfig } from '../../../../core/config';
import { getDataFrameVars, LinkSrv } from '../link_srv';
import { getDataFrameVars, getPanelLinksVariableSuggestions, LinkSrv } from '../link_srv';
jest.mock('app/core/services/context_srv', () => ({
appEvents: {
@@ -261,237 +268,295 @@ describe('linkSrv', () => {
expect(linkSrv.getLinkUrl(anchorInfo)).toBe(anchorInfo.url);
});
});
});
describe('getDataFrameVars', () => {
describe('when called with a DataFrame that contains fields without nested path', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'indoor',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature', type: FieldType.number, values: [10, 11, 12] },
],
describe('getDataFrameVars', () => {
describe('when called with a DataFrame that contains fields without nested path', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'indoor',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature', type: FieldType.number, values: [10, 11, 12] },
],
});
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields.temperature',
label: 'temperature',
documentation: `Formatted value for temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields.temperature.numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields.temperature.text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
]);
});
});
const suggestions = getDataFrameVars([frame]);
describe('when called with a DataFrame that contains fields with nested path', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
],
});
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields.temperature',
label: 'temperature',
documentation: `Formatted value for temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields.temperature.numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields.temperature.text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
]);
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields["temperature.indoor"]',
label: 'temperature.indoor',
documentation: `Formatted value for temperature.indoor on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["temperature.indoor"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["temperature.indoor"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
]);
});
});
describe('when called with a DataFrame that contains fields with displayName', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
],
});
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields["Indoor Temperature"]',
label: 'Indoor Temperature',
documentation: `Formatted value for Indoor Temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"]`,
label: `Select by title`,
documentation: `Use the title to pick the field`,
origin: VariableOrigin.Fields,
},
]);
});
});
describe('when called with a DataFrame that contains fields with duplicate names', () => {
it('then it should ignore duplicates', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
{ name: 'temperature.outdoor', type: FieldType.number, values: [20, 21, 22] },
],
});
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
// Someone makes a mistake when renaming a field
frame.fields[2].config = { ...frame.fields[2].config, displayName: 'Indoor Temperature' };
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields["Indoor Temperature"]',
label: 'Indoor Temperature',
documentation: `Formatted value for Indoor Temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"]`,
label: `Select by title`,
documentation: `Use the title to pick the field`,
origin: VariableOrigin.Fields,
},
]);
});
});
describe('when called with multiple DataFrames', () => {
it('it should not return any suggestions', () => {
const frame1 = toDataFrame({
name: 'server1',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
],
});
const frame2 = toDataFrame({
name: 'server2',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
],
});
const suggestions = getDataFrameVars([frame1, frame2]);
expect(suggestions).toEqual([]);
});
});
});
describe('when called with a DataFrame that contains fields with nested path', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
],
});
describe('getPanelLinksVariableSuggestions', () => {
it('then it should return template variables, json properties and built-ins', () => {
const templateSrvWithJsonValues = initTemplateSrv('key', [
{
type: 'custom',
name: 'customServers',
valuesFormat: 'json',
query: '[{"name":"web","ip":"192.168.0.100"},{"name":"ads","ip":"192.168.0.142"}]',
},
]);
setTemplateSrv(templateSrvWithJsonValues);
const suggestions = getDataFrameVars([frame]);
const suggestions = getPanelLinksVariableSuggestions();
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
value: 'customServers',
label: 'customServers',
origin: VariableOrigin.Template,
},
{
value: '__data.fields["temperature.indoor"]',
label: 'temperature.indoor',
documentation: `Formatted value for temperature.indoor on the same row`,
origin: VariableOrigin.Fields,
value: 'customServers.name',
label: 'customServers.name',
origin: VariableOrigin.Template,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
value: 'customServers.ip',
label: 'customServers.ip',
origin: VariableOrigin.Template,
},
{
value: `__data.fields["temperature.indoor"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
value: `${DataLinkBuiltInVars.includeVars}`,
label: 'All variables',
documentation: 'Adds current variables',
origin: VariableOrigin.Template,
},
{
value: `__data.fields["temperature.indoor"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
value: `${DataLinkBuiltInVars.keepTime}`,
label: 'Time range',
documentation: 'Adds current time range',
origin: VariableOrigin.BuiltIn,
},
{
value: `${DataLinkBuiltInVars.timeRangeFrom}`,
label: 'Time range: from',
documentation: "Adds current time range's from value",
origin: VariableOrigin.BuiltIn,
},
{
value: `${DataLinkBuiltInVars.timeRangeTo}`,
label: 'Time range: to',
documentation: "Adds current time range's to value",
origin: VariableOrigin.BuiltIn,
},
]);
});
});
describe('when called with a DataFrame that contains fields with displayName', () => {
it('then it should return correct suggestions', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
],
});
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields["Indoor Temperature"]',
label: 'Indoor Temperature',
documentation: `Formatted value for Indoor Temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"]`,
label: `Select by title`,
documentation: `Use the title to pick the field`,
origin: VariableOrigin.Fields,
},
]);
});
});
describe('when called with a DataFrame that contains fields with duplicate names', () => {
it('then it should ignore duplicates', () => {
const frame = toDataFrame({
name: 'temperatures',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
{ name: 'temperature.outdoor', type: FieldType.number, values: [20, 21, 22] },
],
});
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
// Someone makes a mistake when renaming a field
frame.fields[2].config = { ...frame.fields[2].config, displayName: 'Indoor Temperature' };
const suggestions = getDataFrameVars([frame]);
expect(suggestions).toEqual([
{
value: '__data.fields.time',
label: 'time',
documentation: `Formatted value for time on the same row`,
origin: VariableOrigin.Fields,
},
{
value: '__data.fields["Indoor Temperature"]',
label: 'Indoor Temperature',
documentation: `Formatted value for Indoor Temperature on the same row`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields[0]`,
label: `Select by index`,
documentation: `Enter the field order`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].numeric`,
label: `Show numeric value`,
documentation: `the numeric field value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"].text`,
label: `Show text value`,
documentation: `the text value`,
origin: VariableOrigin.Fields,
},
{
value: `__data.fields["Indoor Temperature"]`,
label: `Select by title`,
documentation: `Use the title to pick the field`,
origin: VariableOrigin.Fields,
},
]);
});
});
describe('when called with multiple DataFrames', () => {
it('it should not return any suggestions', () => {
const frame1 = toDataFrame({
name: 'server1',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
],
});
const frame2 = toDataFrame({
name: 'server2',
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
],
});
const suggestions = getDataFrameVars([frame1, frame2]);
expect(suggestions).toEqual([]);
});
});
});
+12 -2
View File
@@ -4874,7 +4874,8 @@
"apply": "Apply",
"change-value": "Change variable value",
"discard": "Discard",
"modal-title": "Custom Variable"
"modal-title": "Custom Variable",
"values": "Values separated by comma"
},
"datasource-options": {
"name-filter": "Name filter",
@@ -5968,6 +5969,9 @@
},
"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.<br />Check <4>our docs</4> for more information.",
"name-csv-values": "CSV",
"name-json-values": "JSON",
"name-values-separated-comma": "Values separated by comma",
"selection-options": "Selection options"
},
@@ -6555,6 +6559,11 @@
}
}
},
"use-modal-editor": {
"description": {
"change-variable-query": "Change variable query"
}
},
"use-save-dashboard": {
"message-dashboard-saved": "Dashboard saved"
},
@@ -6638,7 +6647,8 @@
"tooltip-show-usages": "Show usages"
},
"variable-values-preview": {
"preview-of-values": "Preview of values",
"preview-of-values_one": "Preview of values ({{count}})",
"preview-of-values_other": "Preview of values ({{count}})",
"show-more": "Show more"
},
"version-history": {
+11 -11
View File
@@ -3604,11 +3604,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.52.0":
version: 6.52.0
resolution: "@grafana/scenes-react@npm:6.52.0"
"@grafana/scenes-react@npm:v6.52.1":
version: 6.52.1
resolution: "@grafana/scenes-react@npm:6.52.1"
dependencies:
"@grafana/scenes": "npm:6.52.0"
"@grafana/scenes": "npm:6.52.1"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3620,7 +3620,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/7f121bcc4fd50f525c7c3457666ad3a32b04783d322d6715aedb6119538f911d0ec265c9c5b49a80478c1deb99286d36d003399a8831f76b6c483f4458b4ce8b
checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3
languageName: node
linkType: hard
@@ -3650,9 +3650,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.52.0":
version: 6.52.0
resolution: "@grafana/scenes@npm:6.52.0"
"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1":
version: 6.52.1
resolution: "@grafana/scenes@npm:6.52.1"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3672,7 +3672,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/e52e0fb83396776c6cb79f8ac6a8aad0799eb2ccce9d0139f5734a49c3add7a1e3b97f14e0142c95b2bceee3ed8fa97b675b9b94c02382ecd683f470d06ef145
checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb
languageName: node
linkType: hard
@@ -19496,8 +19496,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.52.0"
"@grafana/scenes-react": "npm:6.52.0"
"@grafana/scenes": "npm:v6.52.1"
"@grafana/scenes-react": "npm:v6.52.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"