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
This commit is contained in:
@@ -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}
|
||||
|
||||
+3
-3
@@ -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}>
|
||||
|
||||
+108
-3
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
+193
-12
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user