From a687c4a75744e72a7a911fdfdd0bdeda9c3a6a06 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Fri, 9 May 2025 10:06:15 -0400 Subject: [PATCH] Dashboard: Edit pane - Query variable editor (#105038) * query variable editor * add regex, sort, refresh * make the label and spacing consistent * add test for changing datasource * make the preview label consistent with all labels on the editor --- .../variables/components/QueryEditor.tsx | 8 +- .../components/VariableValuesPreview.tsx | 6 +- .../editors/QueryVariableEditor.test.tsx | 111 +++++++++- .../variables/editors/QueryVariableEditor.tsx | 205 +++++++++++++++++- .../settings/variables/utils.ts | 3 +- public/locales/en-US/grafana.json | 8 + 6 files changed, 318 insertions(+), 23 deletions(-) diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx index a88ebf2b3e9..bfb513efd1e 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx @@ -38,10 +38,10 @@ export function QueryEditor({ if (VariableQueryEditor && isLegacyQueryEditor(VariableQueryEditor, datasource)) { return ( - + Query - + - + Query - + -
+ Preview of values -
+
{previewOptions.map((o, index) => ( 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 9a4182e5429..6dbe4005b54 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 @@ -1,4 +1,4 @@ -import { getByRole, render, screen, act, waitFor } from '@testing-library/react'; +import { getByRole, render, screen, act, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { lastValueFrom, of } from 'rxjs'; @@ -13,12 +13,13 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { setRunRequest } from '@grafana/runtime'; -import { QueryVariable } from '@grafana/scenes'; +import { QueryVariable, TextBoxVariable } from '@grafana/scenes'; import { VariableRefresh, VariableSort } from '@grafana/schema'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; -import { QueryVariableEditor } from './QueryVariableEditor'; +import { QueryVariableEditor, getQueryVariableOptions, Editor } from './QueryVariableEditor'; const defaultDatasource = mockDataSource({ name: 'Default Test Data Source', @@ -360,4 +361,108 @@ describe('QueryVariableEditor', () => { expect(variable.state.allValue).toBe('custom all value and another 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' }); + const result = getQueryVariableOptions(variable); + expect(result).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalledWith('getQueryVariableOptions: variable is not a QueryVariable'); + consoleWarnSpy.mockRestore(); + }); + + it('should return an OptionsPaneItemDescriptor that renders ModalEditor with expected interactions', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type }, + query: 'initial query', + }); + const refreshOptionsSpy = jest.spyOn(variable, 'refreshOptions'); + + const result = getQueryVariableOptions(variable); + + expect(result.length).toBe(1); + const descriptor = result[0]; + expect(descriptor.props.title).toBe('Query Editor'); + + // Mock the parent property that OptionsPaneItem expects + descriptor.parent = new OptionsPaneCategoryDescriptor({ + id: 'mock-parent-id', + title: 'Mock Parent', + }); + + const { queryByRole } = render(descriptor.render()); + const user = userEvent.setup(); + + // 1. Initial state: "Open variable editor" button is visible, Modal is not. + const openEditorButton = screen.getByRole('button', { name: 'Open variable editor' }); + expect(openEditorButton).toBeInTheDocument(); + expect(queryByRole('dialog')).not.toBeInTheDocument(); // Modal has role 'dialog' + + // 2. Opening Modal + await user.click(openEditorButton); + const modal = await screen.findByRole('dialog'); // wait for modal to appear + expect(modal).toBeInTheDocument(); + expect(within(modal).getByText('Query Variable')).toBeInTheDocument(); // Modal title + + // 3. Assert Editor's key elements are rendered + // DataSourcePicker's Field + expect(within(modal).getByLabelText('Data source')).toBeInTheDocument(); + // Regex input placeholder + expect(within(modal).getByPlaceholderText(/text>.*value/i)).toBeInTheDocument(); + // Sort select (check for its current value display) + expect(within(modal).getByText('Disabled')).toBeInTheDocument(); // Default sort is 0 (Disabled) + // Refresh select (check for its current value display) + expect(within(modal).getByRole('radio', { name: /on dashboard load/i })).toBeChecked(); // Default refresh + + // 4. Assert Preview and Close buttons are visible + const previewButton = within(modal).getByRole('button', { name: 'Preview' }); + // To distinguish from the header 'X' (aria-label="Close"), find the span with text "Close" and get its parent button. + const closeButtonTextSpan = within(modal).getByText(/^Close$/); + const closeButton = closeButtonTextSpan.closest('button')!; + expect(previewButton).toBeInTheDocument(); + expect(closeButton).toBeInTheDocument(); + + // 5. Preview button calls variable.refreshOptions() + await user.click(previewButton); + expect(refreshOptionsSpy).toHaveBeenCalledTimes(1); + + // 6. Closing Modal + await user.click(closeButton); + await waitFor(() => { + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); + + refreshOptionsSpy.mockRestore(); + }); +}); + +describe('Editor', () => { + const variable = new QueryVariable({ + datasource: { + uid: defaultDatasource.uid, + type: defaultDatasource.type, + }, + query: '', + regex: '.*', + }); + + it('should update variable state when datasource is changed', async () => { + await act(async () => { + render(); + }); + + const dataSourcePicker = screen.getByLabelText('Data source'); + expect(dataSourcePicker).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(dataSourcePicker); + await user.click(screen.getByText(/prom/i)); + + await waitFor(async () => { + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.datasource).toEqual({ uid: 'mock-ds-3', type: 'prometheus' }); + }); }); 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 fd24c1d8124..21a367451b1 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -1,11 +1,24 @@ -import { FormEvent } from 'react'; -import * as React from 'react'; +import { useState, FormEvent } from 'react'; +import { useAsync } from 'react-use'; import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; -import { QueryVariable, sceneGraph } from '@grafana/scenes'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes'; import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { Box, Button, Field, Modal, TextLink } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +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 { QueryVariableEditorForm } from '../components/QueryVariableForm'; +import { VariableTextAreaField } from '../components/VariableTextAreaField'; +import { VariableValuesPreview } from '../components/VariableValuesPreview'; +import { hasVariableOptions } from '../utils'; interface QueryVariableEditorProps { variable: QueryVariable; @@ -50,15 +63,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito variable.setState({ datasource }); }; const onQueryChange = (query: VariableQueryType) => { - let definition: string; - if (typeof query === 'string') { - definition = query; - } else if (query.hasOwnProperty('query') && typeof query.query === 'string') { - definition = query.query; - } else { - definition = ''; - } - variable.setState({ query, definition }); + variable.setState({ query, definition: getQueryDef(query) }); onRunQuery(); }; @@ -87,3 +92,179 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito /> ); } + +export function getQueryVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] { + if (!(variable instanceof QueryVariable)) { + console.warn('getQueryVariableOptions: variable is not a QueryVariable'); + return []; + } + + return [ + new OptionsPaneItemDescriptor({ + title: t('dashboard-scene.query-variable-form.label-editor', 'Query Editor'), + render: () => , + }), + ]; +} + +export function ModalEditor({ variable }: { variable: QueryVariable }) { + const [isOpen, setIsOpen] = useState(false); + + const onRunQuery = () => { + variable.refreshOptions(); + }; + + return ( + <> + + + + setIsOpen(false)} + > + + + + + + + + ); +} + +export function Editor({ variable }: { variable: QueryVariable }) { + const { datasource: datasourceRef, sort, refresh, query, regex } = variable.useState(); + const { value: timeRange } = sceneGraph.getTimeRange(variable).useState(); + const { value: dsConfig } = useAsync(async () => { + const datasource = await getDataSourceSrv().get(datasourceRef ?? ''); + const VariableQueryEditor = await getVariableQueryEditor(datasource); + const defaultQuery = datasource?.variables?.getDefaultQuery?.(); + + if (!query && defaultQuery) { + const newQuery = + typeof defaultQuery === 'string' ? defaultQuery : { ...defaultQuery, refId: defaultQuery.refId ?? 'A' }; + onQueryChange(newQuery); + } + + return { datasource, VariableQueryEditor }; + }, [datasourceRef]); + const { datasource: selectedDatasource, VariableQueryEditor } = dsConfig ?? {}; + + const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => { + const datasource = getDataSourceRef(dsInstanceSettings); + + if ((variable.state.datasource?.type || '') !== datasource.type) { + variable.setState({ datasource, query: '', definition: '' }); + return; + } + + variable.setState({ datasource }); + }; + + const onQueryChange = (query: VariableQueryType) => { + variable.setState({ query, definition: getQueryDef(query) }); + }; + + const onRegExChange = (event: React.FormEvent) => { + variable.setState({ regex: event.currentTarget.value }); + }; + + const onSortChange = (sort: SelectableValue) => { + variable.setState({ sort: sort.value }); + }; + const onRefreshChange = (refresh: VariableRefresh) => { + variable.setState({ refresh: refresh }); + }; + + const isHasVariableOptions = hasVariableOptions(variable); + + return ( + <> + + + + + {selectedDatasource && VariableQueryEditor && ( + + )} + + + + Optional, if you want to extract part of a series name or metric node segment. + +
+ + Named capture groups can be used to separate the display text and value ( + + see examples + + ). + + + } + // eslint-disable-next-line @grafana/no-untranslated-strings + placeholder="/.*-(?.*)-(?.*)-.*/" + onBlur={onRegExChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} + width={52} + /> + + + + + + {isHasVariableOptions && } + + ); +} + +function getQueryDef(query: VariableQueryType) { + if (typeof query === 'string') { + return query; + } else if (query.hasOwnProperty('query') && typeof query.query === 'string') { + return query.query; + } else { + return ''; + } +} diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts index 56d7fed032b..6d1e15db100 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -30,7 +30,7 @@ import { CustomVariableEditor } from './editors/CustomVariableEditor'; import { DataSourceVariableEditor } from './editors/DataSourceVariableEditor'; import { GroupByVariableEditor } from './editors/GroupByVariableEditor'; import { IntervalVariableEditor } from './editors/IntervalVariableEditor'; -import { QueryVariableEditor } from './editors/QueryVariableEditor'; +import { getQueryVariableOptions, QueryVariableEditor } from './editors/QueryVariableEditor'; import { TextBoxVariableEditor, getTextBoxVariableOptions } from './editors/TextBoxVariableEditor'; interface EditableVariableConfig { @@ -58,6 +58,7 @@ export const EDITABLE_VARIABLES: Record