diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index ccce02984c8..8ab1891e626 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -734,6 +734,8 @@ QueryVariableSpec: { allValue?: string placeholder?: string allowCustomValue: bool | *true + staticOptions?: [...VariableOption] + staticOptionsOrder?: "before" | "after" | "sorted" } // Query variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index b9fd38146b7..de90006e396 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -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) diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index b9fd38146b7..de90006e396 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -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) diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index 98fc8804de6..160adf4e7bf 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -738,6 +738,8 @@ QueryVariableSpec: { allValue?: string placeholder?: string allowCustomValue: bool | *true + staticOptions?: [...VariableOption] + staticOptionsOrder?: "before" | "after" | "sorted" } // Query variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 5b599cb414e..a2d9a4e0250 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1214,24 +1214,26 @@ func NewDashboardQueryVariableKind() *DashboardQueryVariableKind { // Query variable specification // +k8s:openapi-gen=true type DashboardQueryVariableSpec struct { - Name string `json:"name"` - Current DashboardVariableOption `json:"current"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - Refresh DashboardVariableRefresh `json:"refresh"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` - Query DashboardDataQueryKind `json:"query"` - Regex string `json:"regex"` - Sort DashboardVariableSort `json:"sort"` - Definition *string `json:"definition,omitempty"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Placeholder *string `json:"placeholder,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` + Name string `json:"name"` + Current DashboardVariableOption `json:"current"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + Refresh DashboardVariableRefresh `json:"refresh"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + Datasource *DashboardDataSourceRef `json:"datasource,omitempty"` + Query DashboardDataQueryKind `json:"query"` + Regex string `json:"regex"` + Sort DashboardVariableSort `json:"sort"` + Definition *string `json:"definition,omitempty"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + 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"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 85b5a6f65cb..ba0a80e050b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -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"}, }, diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list index d0755a7cd1f..95eb59de600 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index bbf3cb24321..1b325f7300b 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -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) diff --git a/package.json b/package.json index 9b96cf2275e..b0750527330 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 9265c5f9f2c..a4b125c1005 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -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 { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 056f3209ccd..1dcc45d4d30 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', }, diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index fbaa9c8f09b..5b043cd96d0 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -191,6 +191,14 @@ export interface VariableModel { * Options sort order */ sort?: VariableSort; + /** + * Additional static options for query variable + */ + staticOptions?: Array; + /** + * 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 = { multi: false, options: [], skipUrlSync: false, + staticOptions: [], }; /** diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 34ee46e0cff..04fc5092d8a 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -1005,6 +1005,8 @@ export interface QueryVariableSpec { allValue?: string; placeholder?: string; allowCustomValue: boolean; + staticOptions?: VariableOption[]; + staticOptionsOrder?: "before" | "after" | "sorted"; } export const defaultQueryVariableSpec = (): QueryVariableSpec => ({ diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 2d60aa29221..e952d013ca4 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -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"` diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index 2f4dafbaa16..b5e33c9e980 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -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" } } }, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 72cc20f2943..e0a3cfe305d 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -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", }, } `); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 74304a2a9a6..d90254a5de7 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -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); diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx index 3b5fc70d2e9..c643a98b212 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx @@ -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 = { 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) { @@ -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).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(); + }); }); diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx index 8f57cb3f0d5..d6d4d467c9a 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -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) => void; allValue: string; onAllValueChange: (event: FormEvent) => 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 && ( + + )} + Selection options diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx new file mode 100644 index 00000000000..103aa10c54f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx @@ -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 ( + + {optionsLocal.map((option, index) => ( + + handleLabelChange(index, e.currentTarget.value)} + data-testid={ + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput + } + /> + handleValueChange(index, e.currentTarget.value)} + data-testid={ + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput + } + /> + + + + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx index 9dd39275c62..64c14e4cfc1 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx @@ -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' }); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx index cbdd1c63003..48d95fc3be5 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -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) => { @@ -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 ( ); } diff --git a/public/app/features/dashboard-scene/utils/variables.ts b/public/app/features/dashboard-scene/utils/variables.ts index 04d2f16e901..6b6eeae5e77 100644 --- a/public/app/features/dashboard-scene/utils/variables.ts +++ b/public/app/features/dashboard-scene/utils/variables.ts @@ -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({ diff --git a/public/app/features/variables/query/QueryVariableStaticOptions.tsx b/public/app/features/variables/query/QueryVariableStaticOptions.tsx new file mode 100644 index 00000000000..8e7abbb97a3 --- /dev/null +++ b/public/app/features/variables/query/QueryVariableStaticOptions.tsx @@ -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 ( + <> + + Static options + + + + <> + + { + if (e.currentTarget.checked) { + setAreStaticOptionsEnabled(true); + } else { + setAreStaticOptionsEnabled(false); + if (!!staticOptions?.length) { + onStaticOptionsChange(undefined); + } + } + }} + /> + + {areStaticOptionsEnabled && ( + + )} + + + + + {areStaticOptionsEnabled && ( + onStaticOptionsOrderChange(opt.value)} + testId={ + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsOrderDropdown + } + width={25} + /> + )} + + + ); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c518ead9368..e94393e9580 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5837,10 +5837,13 @@ "description-examples": "Named capture groups can be used to separate the display text and value (<1>see examples).", "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", diff --git a/yarn.lock b/yarn.lock index 8a5dbd39aab..102f78735a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"