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
pull/105186/head
Scott Lepper 2 weeks ago committed by GitHub
parent 43748e43bb
commit a687c4a757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx
  2. 6
      public/app/features/dashboard-scene/settings/variables/components/VariableValuesPreview.tsx
  3. 111
      public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
  4. 205
      public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
  5. 3
      public/app/features/dashboard-scene/settings/variables/utils.ts
  6. 8
      public/locales/en-US/grafana.json

@ -38,10 +38,10 @@ export function QueryEditor({
if (VariableQueryEditor && isLegacyQueryEditor(VariableQueryEditor, datasource)) {
return (
<Box marginBottom={2}>
<Text element={'h4'}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.query-editor.query">Query</Trans>
</Text>
<Box marginTop={1}>
<Box marginTop={0.25}>
<VariableQueryEditor
key={datasource.uid}
datasource={datasource}
@ -57,10 +57,10 @@ export function QueryEditor({
if (VariableQueryEditor && isQueryEditor(VariableQueryEditor, datasource)) {
return (
<Box marginBottom={2}>
<Text element={'h4'}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.query-editor.query">Query</Trans>
</Text>
<Box marginTop={1}>
<Box marginTop={0.25}>
<VariableQueryEditor
key={datasource.uid}
datasource={datasource}

@ -4,7 +4,7 @@ import { MouseEvent, useCallback, useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { VariableValueOption } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, useStyles2 } from '@grafana/ui';
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
export interface VariableValuesPreviewProps {
@ -30,9 +30,9 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
<h5>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</h5>
</Text>
<InlineFieldRow>
{previewOptions.map((o, index) => (
<InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}>

@ -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(<Editor variable={variable} />);
});
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' });
});
});

@ -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: () => <ModalEditor variable={variable} />,
}),
];
}
export function ModalEditor({ variable }: { variable: QueryVariable }) {
const [isOpen, setIsOpen] = useState(false);
const onRunQuery = () => {
variable.refreshOptions();
};
return (
<>
<Box display={'flex'} direction={'column'} paddingBottom={1}>
<Button
tooltip={t(
'dashboard.edit-pane.variable.open-editor-tooltip',
'For more variable options open variable editor'
)}
onClick={() => setIsOpen(true)}
size="sm"
fullWidth
>
<Trans i18nKey="dashboard.edit-pane.variable.open-editor">Open variable editor</Trans>
</Button>
</Box>
<Modal
title={t('dashboard.edit-pane.variable.query-options.modal-title', 'Query Variable')}
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
>
<Editor variable={variable} />
<Modal.ButtonRow>
<Button variant="primary" fill="outline" onClick={onRunQuery}>
<Trans i18nKey="dashboard.edit-pane.variable.query-options.preview">Preview</Trans>
</Button>
<Button variant="secondary" fill="outline" onClick={() => setIsOpen(false)}>
<Trans i18nKey="dashboard.edit-pane.variable.query-options.close">Close</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
</>
);
}
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<HTMLTextAreaElement>) => {
variable.setState({ regex: event.currentTarget.value });
};
const onSortChange = (sort: SelectableValue<VariableSort>) => {
variable.setState({ sort: sort.value });
};
const onRefreshChange = (refresh: VariableRefresh) => {
variable.setState({ refresh: refresh });
};
const isHasVariableOptions = hasVariableOptions(variable);
return (
<>
<Field
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
htmlFor="data-source-picker"
>
<DataSourcePicker current={selectedDatasource} onChange={onDataSourceChange} variables={true} width={30} />
</Field>
{selectedDatasource && VariableQueryEditor && (
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
)}
<VariableTextAreaField
defaultValue={regex ?? ''}
name="Regex"
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
<QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
onChange={onSortChange}
sort={sort}
/>
<QueryVariableRefreshSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2}
onChange={onRefreshChange}
refresh={refresh}
/>
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
</>
);
}
function getQueryDef(query: VariableQueryType) {
if (typeof query === 'string') {
return query;
} else if (query.hasOwnProperty('query') && typeof query.query === 'string') {
return query.query;
} else {
return '';
}
}

@ -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<EditableVariableType, EditableVariableCo
name: 'Query',
description: 'Values are fetched from a data source query',
editor: QueryVariableEditor,
getOptions: getQueryVariableOptions,
},
constant: {
name: 'Constant',

@ -3415,6 +3415,11 @@
"name": "Name",
"open-editor": "Open variable editor",
"open-editor-tooltip": "For more variable options open variable editor",
"query-options": {
"close": "Close",
"modal-title": "Query Variable",
"preview": "Preview"
},
"selection-options": {
"allow-custom-values": "Allow custom values",
"allow-custom-values-description": "Enables users to enter values",
@ -4238,6 +4243,9 @@
"query-options": "Query options",
"selection-options": "Selection options"
},
"query-variable-form": {
"label-editor": "Query Editor"
},
"revert-dashboard-modal": {
"body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.",
"title-restore-version": "Restore version"

Loading…
Cancel
Save