QueryVariable: Add static options input (#107514)

* static options for query variable

* add toggle

* fix and add tests

* run the hack codegen thing

* more test fixes

* make betterer happier

* also make typecheck happy

* make betterer happier

* fix i18n key

* tranalte static variables sort label

* gen translations

* update snapshot
pull/106511/head
Domas 6 days ago committed by GitHub
parent baa89f3eac
commit 8eef17cb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
  2. 4
      apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue
  3. 4
      apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue
  4. 2
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue
  5. 11
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
  6. 19
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
  7. 1
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list
  8. 4
      kinds/dashboard/dashboard_kind.cue
  9. 4
      package.json
  10. 2
      packages/grafana-data/src/types/templateVars.ts
  11. 21
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  12. 9
      packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts
  13. 2
      packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
  14. 12
      pkg/kinds/dashboard/dashboard_spec_gen.go
  15. 14
      pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
  16. 29
      public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts
  17. 10
      public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts
  18. 174
      public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
  19. 22
      public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx
  20. 106
      public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx
  21. 69
      public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
  22. 30
      public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
  23. 5
      public/app/features/dashboard-scene/utils/variables.ts
  24. 93
      public/app/features/variables/query/QueryVariableStaticOptions.tsx
  25. 15
      public/locales/en-US/grafana.json
  26. 8
      yarn.lock

@ -734,6 +734,8 @@ QueryVariableSpec: {
allValue?: string
placeholder?: string
allowCustomValue: bool | *true
staticOptions?: [...VariableOption]
staticOptionsOrder?: "before" | "after" | "sorted"
}
// Query variable kind

@ -219,6 +219,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
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
staticOptionsOrder?: "before" | "after" | "sorted"
...
} @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview)

@ -219,6 +219,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
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
staticOptionsOrder?: "before" | "after" | "sorted"
...
} @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview)

@ -738,6 +738,8 @@ QueryVariableSpec: {
allValue?: string
placeholder?: string
allowCustomValue: bool | *true
staticOptions?: [...VariableOption]
staticOptionsOrder?: "before" | "after" | "sorted"
}
// Query variable kind

@ -1232,6 +1232,8 @@ type DashboardQueryVariableSpec struct {
AllValue *string `json:"allValue,omitempty"`
Placeholder *string `json:"placeholder,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"`
StaticOptions []DashboardVariableOption `json:"staticOptions,omitempty"`
StaticOptionsOrder *DashboardQueryVariableSpecStaticOptionsOrder `json:"staticOptionsOrder,omitempty"`
}
// NewDashboardQueryVariableSpec creates a new DashboardQueryVariableSpec object.
@ -1880,6 +1882,15 @@ const (
DashboardTimeSettingsSpecWeekStartSunday DashboardTimeSettingsSpecWeekStart = "sunday"
)
// +k8s:openapi-gen=true
type DashboardQueryVariableSpecStaticOptionsOrder string
const (
DashboardQueryVariableSpecStaticOptionsOrderBefore DashboardQueryVariableSpecStaticOptionsOrder = "before"
DashboardQueryVariableSpecStaticOptionsOrderAfter DashboardQueryVariableSpecStaticOptionsOrder = "after"
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
)
// +k8s:openapi-gen=true
type DashboardPanelKindOrLibraryPanelKind struct {
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`

@ -3327,6 +3327,25 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableSpec(ref common.Re
Format: "",
},
},
"staticOptions": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVariableOption"),
},
},
},
},
},
"staticOptionsOrder": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "current", "hide", "refresh", "skipUrlSync", "query", "regex", "sort", "options", "multi", "includeAll", "allowCustomValue"},
},

@ -20,6 +20,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryGroupSpec,Queries
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryGroupSpec,Transformations
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableSpec,Options
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableSpec,StaticOptions
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardRowsLayoutSpec,Rows
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardSpec,Annotations
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardSpec,Links

@ -215,6 +215,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
// Additional static options for query variable
staticOptions?: [...#VariableOption]
// Ordering of static options in relation to options returned from data source for query variable
staticOptionsOrder?: "before" | "after" | "sorted"
...
} @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview)

@ -289,8 +289,8 @@
"@grafana/plugin-ui": "0.10.7",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^6.27.0",
"@grafana/scenes-react": "^6.27.0",
"@grafana/scenes": "^6.27.1",
"@grafana/scenes-react": "^6.27.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",

@ -116,6 +116,8 @@ export interface QueryVariableModel extends VariableWithMultiSupport {
query: any;
regex: string;
refresh: VariableRefresh;
staticOptions?: VariableOption[];
staticOptionsOrder?: 'before' | 'after' | 'sorted';
}
export interface TextBoxVariableModel extends VariableWithOptions {

@ -489,6 +489,27 @@ export const versionedPages = {
queryOptionsQueryInput: {
'10.4.0': 'data-testid Variable editor Form Default Variable Query Editor textarea',
},
queryOptionsStaticOptionsRow: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options row',
},
queryOptionsStaticOptionsToggle: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options toggle',
},
queryOptionsStaticOptionsLabelInput: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Label input',
},
queryOptionsStaticOptionsValueInput: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Value input',
},
queryOptionsStaticOptionsDeleteButton: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Delete button',
},
queryOptionsStaticOptionsAddButton: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Add button',
},
queryOptionsStaticOptionsOrderDropdown: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Order dropdown',
},
valueGroupsTagsEnabledSwitch: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query UseTags switch',
},

@ -191,6 +191,14 @@ export interface VariableModel {
* Options sort order
*/
sort?: VariableSort;
/**
* Additional static options for query variable
*/
staticOptions?: Array<VariableOption>;
/**
* Ordering of static options in relation to options returned from data source for query variable
*/
staticOptionsOrder?: ('before' | 'after' | 'sorted');
/**
* Type of variable
*/
@ -203,6 +211,7 @@ export const defaultVariableModel: Partial<VariableModel> = {
multi: false,
options: [],
skipUrlSync: false,
staticOptions: [],
};
/**

@ -1005,6 +1005,8 @@ export interface QueryVariableSpec {
allValue?: string;
placeholder?: string;
allowCustomValue: boolean;
staticOptions?: VariableOption[];
staticOptionsOrder?: "before" | "after" | "sorted";
}
export const defaultQueryVariableSpec = (): QueryVariableSpec => ({

@ -725,6 +725,10 @@ 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"`
// Additional static options for query variable
StaticOptions []VariableOption `json:"staticOptions,omitempty"`
// Ordering of static options in relation to options returned from data source for query variable
StaticOptionsOrder *VariableModelStaticOptionsOrder `json:"staticOptionsOrder,omitempty"`
}
// NewVariableModel creates a new VariableModel object.
@ -1045,6 +1049,14 @@ const (
DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates"
)
type VariableModelStaticOptionsOrder string
const (
VariableModelStaticOptionsOrderBefore VariableModelStaticOptionsOrder = "before"
VariableModelStaticOptionsOrderAfter VariableModelStaticOptionsOrder = "after"
VariableModelStaticOptionsOrderSorted VariableModelStaticOptionsOrder = "sorted"
)
type ValueMapOrRangeMapOrRegexMapOrSpecialValueMap struct {
ValueMap *ValueMap `json:"ValueMap,omitempty"`
RangeMap *RangeMap `json:"RangeMap,omitempty"`

@ -3058,6 +3058,20 @@
"sort": {
"type": "string",
"default": ""
},
"staticOptions": {
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardVariableOption"
}
]
}
},
"staticOptionsOrder": {
"type": "string"
}
}
},

@ -101,6 +101,8 @@ describe('sceneVariablesSetToVariables', () => {
allowCustomValue: true,
allValue: 'test-all',
isMulti: true,
staticOptions: [{ label: 'test', value: 'test' }],
staticOptionsOrder: 'after',
});
const set = new SceneVariableSet({
@ -136,6 +138,13 @@ describe('sceneVariablesSetToVariables', () => {
"query": "query",
"refresh": 1,
"regex": "",
"staticOptions": [
{
"text": "test",
"value": "test",
},
],
"staticOptionsOrder": "after",
"type": "query",
}
`);
@ -155,6 +164,8 @@ describe('sceneVariablesSetToVariables', () => {
allValue: 'test-all',
allowCustomValue: false,
isMulti: true,
staticOptions: [{ label: 'test', value: 'test' }],
staticOptionsOrder: 'after',
});
const set = new SceneVariableSet({
variables: [variable],
@ -189,6 +200,13 @@ describe('sceneVariablesSetToVariables', () => {
"query": "query",
"refresh": 1,
"regex": "",
"staticOptions": [
{
"text": "test",
"value": "test",
},
],
"staticOptionsOrder": "after",
"type": "query",
}
`);
@ -221,7 +239,7 @@ describe('sceneVariablesSetToVariables', () => {
expect(result[0].options).toEqual([]);
});
it('should handle Query variable when sceneVariablesSetToVariables shoudl keep options', () => {
it('should handle Query variable when sceneVariablesSetToVariables should keep options', () => {
const variable = new QueryVariable({
name: 'test',
label: 'test-label',
@ -846,6 +864,8 @@ describe('sceneVariablesSetToVariables', () => {
includeAll: true,
allValue: 'test-all',
isMulti: true,
staticOptions: [{ label: 'test', value: 'test' }],
staticOptionsOrder: 'after',
});
const set = new SceneVariableSet({
@ -891,6 +911,13 @@ describe('sceneVariablesSetToVariables', () => {
"regex": "",
"skipUrlSync": false,
"sort": "disabled",
"staticOptions": [
{
"text": "test",
"value": "test",
},
],
"staticOptionsOrder": "after",
},
}
`);

@ -86,6 +86,11 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
multi: variable.state.isMulti,
allowCustomValue: variable.state.allowCustomValue,
skipUrlSync: variable.state.skipUrlSync,
staticOptions: variable.state.staticOptions?.map((option) => ({
text: option.label,
value: String(option.value),
})),
staticOptionsOrder: variable.state.staticOptionsOrder,
});
} else if (sceneUtils.isCustomVariable(variable)) {
variables.push({
@ -324,6 +329,11 @@ export function sceneVariablesSetToSchemaV2Variables(
multi: variable.state.isMulti || false,
skipUrlSync: variable.state.skipUrlSync || false,
allowCustomValue: variable.state.allowCustomValue ?? true,
staticOptions: variable.state.staticOptions?.map((option) => ({
text: option.label,
value: String(option.value),
})),
staticOptionsOrder: variable.state.staticOptionsOrder,
},
};
variables.push(queryVariable);

@ -77,6 +77,8 @@ describe('QueryVariableEditorForm', () => {
const mockOnIncludeAllChange = jest.fn();
const mockOnAllValueChange = jest.fn();
const mockOnAllowCustomValueChange = jest.fn();
const mockOnStaticOptionsChange = jest.fn();
const mockOnStaticOptionsOrderChange = jest.fn();
const defaultProps: React.ComponentProps<typeof QueryVariableEditorForm> = {
datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type },
@ -99,6 +101,8 @@ describe('QueryVariableEditorForm', () => {
allValue: 'custom all value',
onAllValueChange: mockOnAllValueChange,
onAllowCustomValueChange: mockOnAllowCustomValueChange,
onStaticOptionsChange: mockOnStaticOptionsChange,
onStaticOptionsOrderChange: mockOnStaticOptionsOrderChange,
};
async function setup(props?: React.ComponentProps<typeof QueryVariableEditorForm>) {
@ -142,6 +146,10 @@ describe('QueryVariableEditorForm', () => {
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
const staticOptionsToggle = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(regexInput).toBeInTheDocument();
@ -158,6 +166,7 @@ describe('QueryVariableEditorForm', () => {
expect(includeAllSwitch).toBeChecked();
expect(allValueInput).toBeInTheDocument();
expect(allValueInput).toHaveValue('custom all value');
expect(staticOptionsToggle).toBeInTheDocument();
});
it('should call onDataSourceChange when changing the datasource', async () => {
@ -293,4 +302,169 @@ describe('QueryVariableEditorForm', () => {
((mockOnAllValueChange.mock.calls[0][0] as FormEvent<HTMLInputElement>).target as HTMLInputElement).value
).toBe('custom all value and another value');
});
it('should call onStaticOptionsOrderChange when changing the static options order', async () => {
const {
renderer: { getByTestId },
} = await setup();
// First enable static options
const staticOptionsToggle = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
await userEvent.click(staticOptionsToggle);
// Then access the dropdown
const staticOptionsOrderDropdown = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsOrderDropdown
);
await userEvent.click(staticOptionsOrderDropdown); // open the select
const anotherOption = await screen.getByText('After query values');
await userEvent.click(anotherOption);
expect(mockOnStaticOptionsOrderChange).toHaveBeenCalledTimes(1);
expect(mockOnStaticOptionsOrderChange.mock.calls[0][0]).toBe('after');
});
it('should call onStaticOptionsChange when adding a static option', async () => {
const {
renderer: { getByTestId, getAllByTestId },
} = await setup();
// First enable static options
const staticOptionsToggle = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
await userEvent.click(staticOptionsToggle);
const addButton = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
);
await userEvent.click(addButton);
// Now enter label and value for the new option
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
);
// Enter label for the new option (second input)
await userEvent.type(labelInputs[1], 'New Option Label');
await userEvent.type(valueInputs[1], 'new-option-value');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([
{ value: 'new-option-value', label: 'New Option Label' },
]);
});
it('should call onStaticOptionsChange when removing a static option', async () => {
const {
renderer: { getAllByTestId },
} = await setup({
...defaultProps,
staticOptions: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
});
const deleteButtons = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsDeleteButton
);
// Remove the first option
await userEvent.click(deleteButtons[0]);
expect(mockOnStaticOptionsChange).toHaveBeenCalledTimes(1);
// Should call with only the second option remaining
expect(mockOnStaticOptionsChange.mock.calls[0][0]).toEqual([{ value: 'option2', label: 'Option 2' }]);
});
it('should call onStaticOptionsChange when editing a static option label', async () => {
const {
renderer: { getAllByTestId },
} = await setup({
...defaultProps,
staticOptions: [{ value: 'test', label: 'Test Label' }],
});
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
);
await userEvent.clear(labelInputs[0]);
await userEvent.type(labelInputs[0], 'Updated Label');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'test', label: 'Updated Label' }]);
});
it('should call onStaticOptionsChange when editing a static option value', async () => {
const {
renderer: { getAllByTestId },
} = await setup({
...defaultProps,
staticOptions: [{ value: 'old-value', label: 'Test Label' }],
});
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
);
await userEvent.clear(valueInputs[0]);
await userEvent.type(valueInputs[0], 'new-value');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'new-value', label: 'Test Label' }]);
});
it('should remove static options and hide UI elements when static options switch is unchecked', async () => {
const {
renderer: { getByTestId, queryByTestId, getAllByTestId },
} = await setup({
...defaultProps,
staticOptions: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
});
// Static options should be visible initially
expect(
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle)
).toBeChecked();
expect(
getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsOrderDropdown
)
).toBeInTheDocument();
// Option rows should be visible
expect(
getAllByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow)
).toHaveLength(2);
// Uncheck the static options switch
const staticOptionsToggle = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
await userEvent.click(staticOptionsToggle);
// Should call onStaticOptionsChange to remove static options
expect(mockOnStaticOptionsChange).toHaveBeenCalledTimes(1);
expect(mockOnStaticOptionsChange).toHaveBeenCalledWith(undefined);
// Static options UI elements should be hidden
expect(
queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsOrderDropdown
)
).not.toBeInTheDocument();
expect(
queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow)
).not.toBeInTheDocument();
});
});

@ -14,6 +14,11 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
import {
StaticOptionsOrderType,
StaticOptionsType,
QueryVariableStaticOptions,
} from 'app/features/variables/query/QueryVariableStaticOptions';
import { VariableLegend } from './VariableLegend';
import { VariableTextAreaField } from './VariableTextAreaField';
@ -41,6 +46,10 @@ interface QueryVariableEditorFormProps {
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
allValue: string;
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
staticOptions?: StaticOptionsType;
staticOptionsOrder?: StaticOptionsOrderType;
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
}
export function QueryVariableEditorForm({
@ -64,6 +73,10 @@ export function QueryVariableEditorForm({
onIncludeAllChange,
allValue,
onAllValueChange,
staticOptions,
staticOptionsOrder,
onStaticOptionsChange,
onStaticOptionsOrderChange,
}: QueryVariableEditorFormProps) {
const { value: dsConfig } = useAsync(async () => {
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
@ -144,6 +157,15 @@ export function QueryVariableEditorForm({
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
/>
)}
<VariableLegend>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.selection-options">Selection options</Trans>
</VariableLegend>

@ -0,0 +1,106 @@
import { useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, Input, Stack } from '@grafana/ui';
interface VariableOptionsFieldProps {
options: VariableValueOption[];
onChange: (options: VariableValueOption[]) => void;
width?: number;
}
export function VariableOptionsInput({ options, onChange, width }: VariableOptionsFieldProps) {
const [optionsLocal, setOptionsLocal] = useState(options.length ? options : [{ value: '', label: '' }]);
const updateOptions = (newOptions: VariableValueOption[]) => {
setOptionsLocal(newOptions);
onChange(
newOptions
.map((option) => ({
label: option.label.trim(),
value: String(option.value).trim(),
}))
.filter((option) => !!option.label)
);
};
const handleValueChange = (index: number, value: string) => {
if (optionsLocal[index].value !== value) {
const newOptions = [...optionsLocal];
newOptions[index] = { ...newOptions[index], value };
updateOptions(newOptions);
}
};
const handleLabelChange = (index: number, label: string) => {
if (optionsLocal[index].label !== label) {
const newOptions = [...optionsLocal];
newOptions[index] = { ...newOptions[index], label };
updateOptions(newOptions);
}
};
const addOption = () => {
const newOption: VariableValueOption = { value: '', label: '' };
const newOptions = [...optionsLocal, newOption];
updateOptions(newOptions);
};
const removeOption = (index: number) => {
const newOptions = optionsLocal.filter((_, i) => i !== index);
updateOptions(newOptions);
};
return (
<Stack direction="column" gap={2} width={width}>
{optionsLocal.map((option, index) => (
<Stack
direction="row"
key={index}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow}
>
<Input
value={option.label}
placeholder={t('variables.query-variable-static-options.label-placeholder', 'display label')}
onChange={(e) => handleLabelChange(index, e.currentTarget.value)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
}
/>
<Input
value={String(option.value)}
placeholder={t('variables.query-variable-static-options.value-placeholder', 'value, default empty string')}
onChange={(e) => handleValueChange(index, e.currentTarget.value)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
}
/>
<Button
icon="times"
variant="secondary"
aria-label={t('variables.query-variable-static-options.remove-option-button-label', 'Remove option')}
onClick={() => removeOption(index)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsDeleteButton
}
/>
</Stack>
))}
<div>
<Button
icon="plus"
variant="secondary"
onClick={addOption}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
}
aria-label={t('variables.query-variable-static-options.add-option-button-label', 'Add option')}
>
<Trans i18nKey="variables.query-variable-static-options.add-option-button-label">Add option</Trans>
</Button>
</div>
</Stack>
);
}

@ -123,6 +123,10 @@ describe('QueryVariableEditor', () => {
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
const staticOptionsToggle = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(queryEditor).toBeInTheDocument();
@ -141,6 +145,7 @@ describe('QueryVariableEditor', () => {
expect(includeAllSwitch).toBeChecked();
expect(allValueInput).toBeInTheDocument();
expect(allValueInput).toHaveValue('custom all value');
expect(staticOptionsToggle).toBeInTheDocument();
});
it('should update the variable with default query for the selected DS', async () => {
@ -362,6 +367,70 @@ describe('QueryVariableEditor', () => {
expect(variable.state.allValue).toBe('custom all value and another value');
});
it('should update the variable state when adding two static options', async () => {
const {
variable,
renderer: { getByTestId, getAllByTestId },
user,
} = await setup();
// Initially no static options
expect(variable.state.staticOptions).toBeUndefined();
// First enable static options
const staticOptionsToggle = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
);
await userEvent.click(staticOptionsToggle);
// Add first static option
const addButton = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
);
await user.click(addButton);
// Enter label and value for first option
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
);
await user.type(labelInputs[0], 'First Option');
await user.type(valueInputs[0], 'first-value');
await waitFor(async () => {
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.staticOptions).toEqual([{ label: 'First Option', value: 'first-value' }]);
// Add second static option
await user.click(addButton);
// Get updated inputs (now there should be 2 sets)
const updatedLabelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
);
const updatedValueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
);
// Enter label and value for second option
await user.type(updatedLabelInputs[1], 'Second Option');
await user.type(updatedValueInputs[1], 'second-value');
await waitFor(async () => {
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.staticOptions).toEqual([
{ label: 'First Option', value: 'first-value' },
{ label: 'Second Option', value: 'second-value' },
]);
});
it('should return an empty array if variable is not a QueryVariable', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const variable = new TextBoxVariable({ name: 'test', value: 'test value' });

@ -14,6 +14,7 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
import { StaticOptionsOrderType, StaticOptionsType } from 'app/features/variables/query/QueryVariableStaticOptions';
import { QueryVariableEditorForm } from '../components/QueryVariableForm';
import { VariableTextAreaField } from '../components/VariableTextAreaField';
@ -27,8 +28,19 @@ interface QueryVariableEditorProps {
type VariableQueryType = QueryVariable['state']['query'];
export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEditorProps) {
const { datasource, regex, sort, refresh, isMulti, includeAll, allValue, query, allowCustomValue } =
variable.useState();
const {
datasource,
regex,
sort,
refresh,
isMulti,
includeAll,
allValue,
query,
allowCustomValue,
staticOptions,
staticOptionsOrder,
} = variable.useState();
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
const onRegExChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
@ -67,6 +79,16 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
onRunQuery();
};
const onStaticOptionsChange = (staticOptions: StaticOptionsType) => {
onRunQuery();
variable.setState({ staticOptions });
};
const onStaticOptionsOrderChange = (staticOptionsOrder: StaticOptionsOrderType) => {
onRunQuery();
variable.setState({ staticOptionsOrder });
};
return (
<QueryVariableEditorForm
datasource={datasource ?? undefined}
@ -89,6 +111,10 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
onAllValueChange={onAllValueChange}
allowCustomValue={allowCustomValue}
onAllowCustomValueChange={onAllowCustomValueChange}
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
/>
);
}

@ -190,6 +190,11 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
hide: variable.hide,
definition: variable.definition,
allowCustomValue: variable.allowCustomValue,
staticOptions: variable.staticOptions?.map((option) => ({
label: String(option.text),
value: String(option.value),
})),
staticOptionsOrder: variable.staticOptionsOrder,
});
} else if (variable.type === 'datasource') {
return new DataSourceVariable({

@ -0,0 +1,93 @@
import { useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { QueryVariable } from '@grafana/scenes';
import { Field, Stack, Switch } from '@grafana/ui';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableOptionsInput } from 'app/features/dashboard-scene/settings/variables/components/VariableOptionsInput';
import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField';
export type StaticOptionsType = QueryVariable['state']['staticOptions'];
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
interface QueryVariableStaticOptionsProps {
staticOptions: StaticOptionsType;
staticOptionsOrder: StaticOptionsOrderType;
onStaticOptionsChange: (staticOptions: StaticOptionsType) => void;
onStaticOptionsOrderChange: (staticOptionsOrder: StaticOptionsOrderType) => void;
}
const SORT_OPTIONS = [
{ label: 'Before query values', value: 'before' },
{ label: 'After query values', value: 'after' },
{ label: 'Sorted with query values', value: 'sorted' },
];
export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProps) {
const { staticOptions, onStaticOptionsChange, staticOptionsOrder, onStaticOptionsOrderChange } = props;
const value = SORT_OPTIONS.find((o) => o.value === staticOptionsOrder) ?? SORT_OPTIONS[0];
const [areStaticOptionsEnabled, setAreStaticOptionsEnabled] = useState(!!staticOptions?.length);
return (
<>
<VariableLegend>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.static-options-legend">Static options</Trans>
</VariableLegend>
<Stack direction="column" gap={2}>
<Field
noMargin
label={t('dashboard-scene.query-variable-editor-form.label-use-static-options', 'Use static options')}
description={t(
'variables.query-variable-static-options.description',
'Add custom options in addition to query results'
)}
>
<>
<Stack direction="column" gap={2}>
<Switch
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsToggle
}
value={areStaticOptionsEnabled}
onChange={(e) => {
if (e.currentTarget.checked) {
setAreStaticOptionsEnabled(true);
} else {
setAreStaticOptionsEnabled(false);
if (!!staticOptions?.length) {
onStaticOptionsChange(undefined);
}
}
}}
/>
{areStaticOptionsEnabled && (
<VariableOptionsInput width={60} options={staticOptions ?? []} onChange={onStaticOptionsChange} />
)}
</Stack>
</>
</Field>
{areStaticOptionsEnabled && (
<VariableSelectField
name={t('dashboard-scene.query-variable-editor-form.label-static-options-sort', 'Static options sort')}
description={t(
'variables.query-variable-static-options-sort-select.description-values-variable',
'How to sort static options with query results'
)}
value={value}
options={SORT_OPTIONS}
onChange={(opt) => onStaticOptionsOrderChange(opt.value)}
testId={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsOrderDropdown
}
width={25}
/>
)}
</Stack>
</>
);
}

@ -5837,10 +5837,13 @@
"description-examples": "Named capture groups can be used to separate the display text and value (<1>see examples</1>).",
"description-optional": "Optional, if you want to extract part of a series name or metric node segment.",
"label-data-source": "Data source",
"label-static-options-sort": "Static options sort",
"label-target-data-source": "Target data source",
"label-use-static-options": "Use static options",
"name-regex": "Regex",
"query-options": "Query options",
"selection-options": "Selection options"
"selection-options": "Selection options",
"static-options-legend": "Static options"
},
"resource-export": {
"label": {
@ -13158,6 +13161,16 @@
}
}
},
"query-variable-static-options": {
"add-option-button-label": "Add option",
"description": "Add custom options in addition to query results",
"label-placeholder": "display label",
"remove-option-button-label": "Remove option",
"value-placeholder": "value, default empty string"
},
"query-variable-static-options-sort-select": {
"description-values-variable": "How to sort static options with query results"
},
"text-box-variable-editor": {
"name-default-value": "Default value",
"placeholder-default-value-if-any": "default value, if any",

@ -3549,7 +3549,7 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:^6.27.0":
"@grafana/scenes-react@npm:^6.27.1":
version: 6.27.2
resolution: "@grafana/scenes-react@npm:6.27.2"
dependencies:
@ -3569,7 +3569,7 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.27.2, @grafana/scenes@npm:^6.27.0":
"@grafana/scenes@npm:6.27.2, @grafana/scenes@npm:^6.27.1":
version: 6.27.2
resolution: "@grafana/scenes@npm:6.27.2"
dependencies:
@ -18159,8 +18159,8 @@ __metadata:
"@grafana/plugin-ui": "npm:0.10.7"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:^6.27.0"
"@grafana/scenes-react": "npm:^6.27.0"
"@grafana/scenes": "npm:^6.27.1"
"@grafana/scenes-react": "npm:^6.27.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"

Loading…
Cancel
Save