diff --git a/.betterer.results b/.betterer.results index ba53b876c21..1f976380ac4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4330,9 +4330,6 @@ exports[`better eslint`] = { "public/app/features/variables/datasource/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/variables/editor/LegacyVariableQueryEditor.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "public/app/features/variables/editor/VariableEditorContainer.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 285b0447aa2..20bb47d9029 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -145,14 +145,14 @@ export const Pages = { applyButton: 'data-testid Variable editor Apply button', }, QueryVariable: { - queryOptionsDataSourceSelect: Components.DataSourcePicker.container, + queryOptionsDataSourceSelect: Components.DataSourcePicker.inputV2, queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select', queryOptionsRefreshSelectV2: 'data-testid Variable editor Form Query Refresh select', queryOptionsRegExInput: 'Variable editor Form Query RegEx field', queryOptionsRegExInputV2: 'data-testid Variable editor Form Query RegEx field', queryOptionsSortSelect: 'Variable editor Form Query Sort select', queryOptionsSortSelectV2: 'data-testid Variable editor Form Query Sort select', - queryOptionsQueryInput: 'Variable editor Form Default Variable Query Editor textarea', + queryOptionsQueryInput: 'data-testid Variable editor Form Default Variable Query Editor textarea', valueGroupsTagsEnabledSwitch: 'Variable editor Form Query UseTags switch', valueGroupsTagsTagsQueryInput: 'Variable editor Form Query TagsQuery field', valueGroupsTagsTagsValuesQueryInput: 'Variable editor Form Query TagsValuesQuery field', diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx new file mode 100644 index 00000000000..18436c85c2b --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryEditor.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { DataSourceApi, LoadingState, TimeRange } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; +import { QueryVariable } from '@grafana/scenes'; +import { Text, Box } from '@grafana/ui'; +import { isLegacyQueryEditor, isQueryEditor } from 'app/features/variables/guard'; +import { VariableQueryEditorType } from 'app/features/variables/types'; + +type VariableQueryType = QueryVariable['state']['query']; + +interface QueryEditorProps { + query: VariableQueryType; + datasource: DataSourceApi; + VariableQueryEditor: VariableQueryEditorType; + timeRange: TimeRange; + onLegacyQueryChange: (query: VariableQueryType, definition: string) => void; + onQueryChange: (query: VariableQueryType) => void; +} + +export function QueryEditor({ + query, + datasource, + VariableQueryEditor, + onLegacyQueryChange, + onQueryChange, + timeRange, +}: QueryEditorProps) { + let queryWithDefaults; + if (typeof query === 'string') { + queryWithDefaults = query || (datasource.variables?.getDefaultQuery?.() ?? ''); + } else { + queryWithDefaults = { + ...datasource.variables?.getDefaultQuery?.(), + ...query, + }; + } + + if (VariableQueryEditor && isLegacyQueryEditor(VariableQueryEditor, datasource)) { + return ( + + Query + + + + + ); + } + + if (VariableQueryEditor && isQueryEditor(VariableQueryEditor, datasource)) { + return ( + + Query + + {}} + data={{ series: [], state: LoadingState.Done, timeRange }} + range={timeRange} + onBlur={() => {}} + history={[]} + /> + + + ); + } + + return null; +} 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 new file mode 100644 index 00000000000..8e37364ccc7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx @@ -0,0 +1,269 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { FormEvent } from 'react'; +import { of } from 'rxjs'; +import { MockDataSourceApi } from 'test/mocks/datasource_srv'; + +import { + LoadingState, + PanelData, + getDefaultTimeRange, + toDataFrame, + FieldType, + VariableSupportType, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { QueryVariableEditorForm } from './QueryVariableForm'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +describe('QueryVariableEditorForm', () => { + const mockOnDataSourceChange = jest.fn(); + const mockOnQueryChange = jest.fn(); + const mockOnLegacyQueryChange = jest.fn(); + const mockOnRegExChange = jest.fn(); + const mockOnSortChange = jest.fn(); + const mockOnRefreshChange = jest.fn(); + const mockOnMultiChange = jest.fn(); + const mockOnIncludeAllChange = jest.fn(); + const mockOnAllValueChange = jest.fn(); + + const defaultProps = { + datasource: new MockDataSourceApi(promDatasource.name, undefined, promDatasource.meta), + onDataSourceChange: mockOnDataSourceChange, + query: 'my-query', + onQueryChange: mockOnQueryChange, + onLegacyQueryChange: mockOnLegacyQueryChange, + timeRange: getDefaultTimeRange(), + VariableQueryEditor: LegacyVariableQueryEditor, + regex: '.*', + onRegExChange: mockOnRegExChange, + sort: VariableSort.alphabeticalAsc, + onSortChange: mockOnSortChange, + refresh: VariableRefresh.onDashboardLoad, + onRefreshChange: mockOnRefreshChange, + isMulti: true, + onMultiChange: mockOnMultiChange, + includeAll: true, + onIncludeAllChange: mockOnIncludeAllChange, + allValue: 'custom all value', + onAllValueChange: mockOnAllValueChange, + }; + + function setup(props?: React.ComponentProps) { + return { + renderer: render(), + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component with initializing the components correctly', () => { + const { + renderer: { getByTestId, getByRole }, + } = setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container); + //const queryEditor = getByTestId('query-editor'); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker).toHaveTextContent('Default Test Data Source'); + expect(regexInput).toBeInTheDocument(); + expect(regexInput).toHaveValue('.*'); + expect(sortSelect).toBeInTheDocument(); + expect(sortSelect).toHaveTextContent('Alphabetical (asc)'); + expect(refreshSelect).toBeInTheDocument(); + expect(getByRole('radio', { name: 'On dashboard load' })).toBeChecked(); + expect(multiSwitch).toBeInTheDocument(); + expect(multiSwitch).toBeChecked(); + expect(includeAllSwitch).toBeInTheDocument(); + expect(includeAllSwitch).toBeChecked(); + expect(allValueInput).toBeInTheDocument(); + expect(allValueInput).toHaveValue('custom all value'); + }); + + it('should call onDataSourceChange when changing the datasource', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2); + await waitFor(async () => { + await userEvent.click(dataSourcePicker); // open the select + await userEvent.tab(); + }); + expect(mockOnDataSourceChange).toHaveBeenCalledTimes(1); + expect(mockOnDataSourceChange).toHaveBeenCalledWith(defaultDatasource); + }); + + it('should call onQueryChange when changing the query', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const queryEditor = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + + await waitFor(async () => { + await userEvent.type(queryEditor, '-new'); + await userEvent.tab(); + }); + + expect(mockOnLegacyQueryChange).toHaveBeenCalledTimes(1); + expect(mockOnLegacyQueryChange).toHaveBeenCalledWith('my-query-new', expect.anything()); + }); + + it('should call onRegExChange when changing the regex', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + await userEvent.type(regexInput, '{backspace}?'); + await userEvent.tab(); + expect(mockOnRegExChange).toHaveBeenCalledTimes(1); + expect( + ((mockOnRegExChange.mock.calls[0][0] as FormEvent).target as HTMLTextAreaElement).value + ).toBe('.?'); + }); + + it('should call onSortChange when changing the sort', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + await userEvent.click(sortSelect); // open the select + const anotherOption = await screen.getByText('Alphabetical (desc)'); + await userEvent.click(anotherOption); + + expect(mockOnSortChange).toHaveBeenCalledTimes(1); + expect(mockOnSortChange).toHaveBeenCalledWith( + expect.objectContaining({ value: VariableSort.alphabeticalDesc }), + expect.anything() + ); + }); + + it('should call onRefreshChange when changing the refresh', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + await userEvent.click(refreshSelect); // open the select + const anotherOption = await screen.getByText('On time range change'); + await userEvent.click(anotherOption); + + expect(mockOnRefreshChange).toHaveBeenCalledTimes(1); + expect(mockOnRefreshChange).toHaveBeenCalledWith(VariableRefresh.onTimeRangeChanged); + }); + + it('should call onMultiChange when changing the multi switch', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + await userEvent.click(multiSwitch); + expect(mockOnMultiChange).toHaveBeenCalledTimes(1); + expect( + (mockOnMultiChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement + ).toBeChecked(); + }); + + it('should call onIncludeAllChange when changing the include all switch', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + await userEvent.click(includeAllSwitch); + expect(mockOnIncludeAllChange).toHaveBeenCalledTimes(1); + expect( + (mockOnIncludeAllChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement + ).toBeChecked(); + }); + + it('should call onAllValueChange when changing the all value', async () => { + const { + renderer: { getByTestId }, + } = setup(); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + await userEvent.type(allValueInput, ' and another value'); + await userEvent.tab(); + expect(mockOnAllValueChange).toHaveBeenCalledTimes(1); + expect( + ((mockOnAllValueChange.mock.calls[0][0] as FormEvent).target as HTMLInputElement).value + ).toBe('custom all value and another value'); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx new file mode 100644 index 00000000000..0e7d86740cc --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -0,0 +1,128 @@ +import React, { FormEvent } from 'react'; + +import { DataSourceApi, DataSourceInstanceSettings, SelectableValue, TimeRange } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { QueryVariable } from '@grafana/scenes'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { Field } from '@grafana/ui'; +import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor'; +import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect'; +import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect'; +import { VariableQueryEditorType } from 'app/features/variables/types'; + +import { VariableLegend } from './VariableLegend'; +import { VariableTextAreaField } from './VariableTextAreaField'; + +type VariableQueryType = QueryVariable['state']['query']; + +interface QueryVariableEditorFormProps { + datasource: DataSourceApi | undefined; + onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void; + query: VariableQueryType; + onQueryChange: (query: VariableQueryType) => void; + onLegacyQueryChange: (query: VariableQueryType, definition: string) => void; + VariableQueryEditor: VariableQueryEditorType | undefined; + timeRange: TimeRange; + regex: string | null; + onRegExChange: (event: FormEvent) => void; + sort: VariableSort; + onSortChange: (option: SelectableValue) => void; + refresh: VariableRefresh; + onRefreshChange: (option: VariableRefresh) => void; + isMulti: boolean; + onMultiChange: (event: FormEvent) => void; + includeAll: boolean; + onIncludeAllChange: (event: FormEvent) => void; + allValue: string; + onAllValueChange: (event: FormEvent) => void; +} + +export function QueryVariableEditorForm({ + datasource, + onDataSourceChange, + query, + onQueryChange, + onLegacyQueryChange, + VariableQueryEditor, + timeRange, + regex, + onRegExChange, + sort, + onSortChange, + refresh, + onRefreshChange, + isMulti, + onMultiChange, + includeAll, + onIncludeAllChange, + allValue, + onAllValueChange, +}: QueryVariableEditorFormProps) { + return ( + <> + Query options + + + + + {datasource && 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 + + ). + + } + placeholder="/.*-(?.*)-(?.*)-.*/" + onBlur={onRegExChange} + testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} + width={52} + /> + + + + + + Selection options + + + ); +} diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx index 17f273dab46..9f0024a7ab1 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { PropsWithChildren, ReactElement, useId } from 'react'; +import React, { PropsWithChildren, useId } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Field, Select, useStyles2 } from '@grafana/ui'; @@ -22,7 +22,7 @@ export function VariableSelectField({ onChange, testId, width, -}: PropsWithChildren>): ReactElement { +}: PropsWithChildren>) { const styles = useStyles2(getStyles); const uniqueId = useId(); const inputId = `variable-select-input-${name}-${uniqueId}`; 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 new file mode 100644 index 00000000000..867ecb3e836 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx @@ -0,0 +1,288 @@ +import { getByRole, render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { lastValueFrom, of } from 'rxjs'; + +import { + VariableSupportType, + PanelData, + LoadingState, + toDataFrame, + getDefaultTimeRange, + FieldType, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { setRunRequest } from '@grafana/runtime'; +import { QueryVariable } from '@grafana/scenes'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor'; + +import { QueryVariableEditor } from './QueryVariableEditor'; + +const defaultDatasource = mockDataSource({ + name: 'Default Test Data Source', + type: 'test', +}); + +const promDatasource = mockDataSource({ + name: 'Prometheus', + type: 'prometheus', +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({ + ...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'), + getDataSourceSrv: () => ({ + get: async () => ({ + ...defaultDatasource, + variables: { + getType: () => VariableSupportType.Custom, + query: jest.fn(), + editor: jest.fn().mockImplementation(LegacyVariableQueryEditor), + }, + }), + getList: () => [defaultDatasource, promDatasource], + getInstanceSettings: () => ({ ...defaultDatasource }), + }), +})); + +const runRequestMock = jest.fn().mockReturnValue( + of({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], + }), + ], + timeRange: getDefaultTimeRange(), + }) +); + +setRunRequest(runRequestMock); + +describe('QueryVariableEditor', () => { + const onRunQueryMock = jest.fn(); + + async function setup(props?: React.ComponentProps) { + const variable = new QueryVariable({ + datasource: { + uid: defaultDatasource.uid, + type: defaultDatasource.type, + }, + query: 'my-query', + regex: '.*', + sort: VariableSort.alphabeticalAsc, + refresh: VariableRefresh.onDashboardLoad, + isMulti: true, + includeAll: true, + allValue: 'custom all value', + }); + + return { + renderer: await act(() => { + return render(); + }), + variable, + user: userEvent.setup(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component with initializing the components correctly', async () => { + const { renderer } = await setup(); + const dataSourcePicker = renderer.getByTestId(selectors.components.DataSourcePicker.container); + const queryEditor = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + const regexInput = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + const sortSelect = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + const refreshSelect = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + const multiSwitch = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const includeAllSwitch = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = renderer.getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(dataSourcePicker).toBeInTheDocument(); + expect(dataSourcePicker).toHaveTextContent('Default Test Data Source'); + expect(queryEditor).toBeInTheDocument(); + expect(queryEditor).toHaveValue('my-query'); + expect(regexInput).toBeInTheDocument(); + expect(regexInput).toHaveValue('.*'); + expect(sortSelect).toBeInTheDocument(); + expect(sortSelect).toHaveTextContent('Alphabetical (asc)'); + expect(refreshSelect).toBeInTheDocument(); + expect(getByRole(refreshSelect, 'radio', { name: 'On dashboard load' })).toBeChecked(); + expect(multiSwitch).toBeInTheDocument(); + expect(multiSwitch).toBeChecked(); + expect(includeAllSwitch).toBeInTheDocument(); + expect(includeAllSwitch).toBeChecked(); + expect(allValueInput).toBeInTheDocument(); + expect(allValueInput).toHaveValue('custom all value'); + }); + + it('should update variable state when changing the datasource', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container).getElementsByTagName('input'); + + await waitFor(async () => { + await user.type(dataSourcePicker[0], 'm'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.datasource).toEqual({ uid: 'mock-ds-2', type: 'test' }); + }); + + it('should update the variable state when changing the query', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const queryEditor = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput + ); + + await waitFor(async () => { + await user.type(queryEditor, '-new'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.query).toEqual('my-query-new'); + expect(onRunQueryMock).toHaveBeenCalledTimes(1); + }); + + it('should update the variable state when changing the regex', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const regexInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2 + ); + + await waitFor(async () => { + await user.type(regexInput, '{backspace}?'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.regex).toBe('.?'); + }); + + it('should update the variable state when changing the sort', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const sortSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2 + ); + + await waitFor(async () => { + await user.click(sortSelect); + const anotherOption = await screen.getByText('Alphabetical (desc)'); + await user.click(anotherOption); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.sort).toBe(VariableSort.alphabeticalDesc); + }); + + it('should update the variable state when changing the refresh', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const refreshSelect = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelectV2 + ); + + await waitFor(async () => { + await user.click(refreshSelect); + const anotherOption = await screen.getByText('On time range change'); + await user.click(anotherOption); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.refresh).toBe(VariableRefresh.onTimeRangeChanged); + }); + + it('should update the variable state when changing the multi switch', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const multiSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + + await waitFor(async () => { + await user.click(multiSwitch); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.isMulti).toBe(false); + }); + + it('should update the variable state when changing the include all switch', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const includeAllSwitch = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + + await waitFor(async () => { + await user.click(includeAllSwitch); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.includeAll).toBe(false); + }); + + it('should update the variable state when changing the all value', async () => { + const { + variable, + renderer: { getByTestId }, + user, + } = await setup(); + const allValueInput = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + await waitFor(async () => { + await user.type(allValueInput, ' and another value'); + await user.tab(); + await lastValueFrom(variable.validateAndUpdate()); + }); + + expect(variable.state.allValue).toBe('custom all value and another 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 57cf0b39ef4..0c577d74b6b 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -1,12 +1,80 @@ -import React from 'react'; +import React, { FormEvent } from 'react'; +import { useAsync } from 'react-use'; -import { QueryVariable } from '@grafana/scenes'; +import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { QueryVariable, sceneGraph } from '@grafana/scenes'; +import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; +import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor'; + +import { QueryVariableEditorForm } from '../components/QueryVariableForm'; interface QueryVariableEditorProps { variable: QueryVariable; - onChange: (variable: QueryVariable) => void; + onRunQuery: () => void; } +type VariableQueryType = QueryVariable['state']['query']; + +export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEditorProps) { + const { datasource: datasourceRef, regex, sort, refresh, isMulti, includeAll, allValue, query } = 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); + + return { datasource, VariableQueryEditor }; + }, [datasourceRef]); + const { datasource, VariableQueryEditor } = dsConfig ?? {}; + + 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 onMultiChange = (event: FormEvent) => { + variable.setState({ isMulti: event.currentTarget.checked }); + }; + const onIncludeAllChange = (event: FormEvent) => { + variable.setState({ includeAll: event.currentTarget.checked }); + }; + const onAllValueChange = (event: FormEvent) => { + variable.setState({ allValue: event.currentTarget.value }); + }; + const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => { + const datasource: DataSourceRef = { uid: dsInstanceSettings.uid, type: dsInstanceSettings.type }; + variable.setState({ datasource }); + }; + const onQueryChange = (query: VariableQueryType) => { + variable.setState({ query }); + onRunQuery(); + }; -export function QueryVariableEditor(props: QueryVariableEditorProps) { - return
QueryVariableEditor
; + return ( + + ); } diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts index 6d43b34891a..9cdafc13411 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.test.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts @@ -115,7 +115,7 @@ describe('getVariableEditor', () => { }); it.each(Object.keys(EDITABLE_VARIABLES) as EditableVariableType[])( - 'should define an editor for every variable type', + 'should define an editor for variable type "%s"', (type) => { const editor = getVariableEditor(type); expect(editor).toBeDefined(); @@ -130,7 +130,7 @@ describe('getVariableEditor', () => { ['datasource', DataSourceVariableEditor], ['adhoc', AdHocFiltersVariableEditor], ['textbox', TextBoxVariableEditor], - ])('should return the correct editor for each variable type', (type, ExpectedVariableEditor) => { + ])('should return the correct editor for variable type "%s"', (type, ExpectedVariableEditor) => { expect(getVariableEditor(type as EditableVariableType)).toBe(ExpectedVariableEditor); }); }); diff --git a/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx b/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx index 069aed31fb0..2b5f3e24317 100644 --- a/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx +++ b/public/app/features/variables/editor/LegacyVariableQueryEditor.tsx @@ -34,7 +34,7 @@ export const LegacyVariableQueryEditor = ({ onChange, query }: VariableQueryEdit onBlur={onBlur} placeholder="Metric name or tags query" required - aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} + data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} cols={52} className={styles.textarea} /> diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 03a1884428e..264d53fd6a8 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { DataSourceApi, VariableSupportType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; @@ -144,7 +145,7 @@ describe('QueryVariableEditor', () => { }); const getQueryField = () => - screen.getByRole('textbox', { name: /variable editor form default variable query editor textarea/i }); + screen.getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput); const getRegExField = () => screen.getByLabelText(/Regex/); diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index 8cdea8c630d..1f4e0016815 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -1,28 +1,19 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { DataSourceInstanceSettings, getDataSourceRef, LoadingState, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { getTemplateSrv } from '@grafana/runtime'; -import { Field, Text, Box } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { DataSourceInstanceSettings, getDataSourceRef, SelectableValue } from '@grafana/data'; +import { QueryVariableEditorForm } from 'app/features/dashboard-scene/settings/variables/components/QueryVariableForm'; import { StoreState } from '../../../types'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; -import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend'; -import { VariableTextAreaField } from '../../dashboard-scene/settings/variables/components/VariableTextAreaField'; -import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; import { initialVariableEditorState } from '../editor/reducer'; import { getQueryVariableEditorState } from '../editor/selectors'; -import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; -import { isLegacyQueryEditor, isQueryEditor } from '../guard'; +import { VariableEditorProps } from '../editor/types'; import { changeVariableMultiValue } from '../state/actions'; import { getVariablesState } from '../state/selectors'; -import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types'; +import { QueryVariableModel, VariableRefresh, VariableSort } from '../types'; import { toKeyedVariableIdentifier } from '../utils'; -import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect'; -import { QueryVariableSortSelect } from './QueryVariableSortSelect'; import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions'; const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { @@ -105,10 +96,6 @@ export class QueryVariableEditorUnConnected extends PureComponent } }; - onRegExChange = (event: FormEvent) => { - this.setState({ regex: event.currentTarget.value }); - }; - onRegExBlur = async (event: FormEvent) => { const regex = event.currentTarget.value; if (this.props.variable.regex !== regex) { @@ -124,11 +111,19 @@ export class QueryVariableEditorUnConnected extends PureComponent this.props.onPropChange({ propName: 'sort', propValue: option.value, updateOptions: true }); }; - onSelectionOptionsChange = async ({ propValue, propName }: OnPropChangeArguments) => { - this.props.onPropChange({ propName, propValue, updateOptions: true }); + onMultiChange = (event: FormEvent) => { + this.props.onPropChange({ propName: 'multi', propValue: event.currentTarget.checked }); + }; + + onIncludeAllChange = (event: FormEvent) => { + this.props.onPropChange({ propName: 'includeAll', propValue: event.currentTarget.checked }); + }; + + onAllValueChange = (event: FormEvent) => { + this.props.onPropChange({ propName: 'allValue', propValue: event.currentTarget.value }); }; - renderQueryEditor = () => { + render() { const { extended, variable } = this.props; if (!extended || !extended.dataSource || !extended.VariableQueryEditor) { @@ -137,112 +132,30 @@ export class QueryVariableEditorUnConnected extends PureComponent const datasource = extended.dataSource; const VariableQueryEditor = extended.VariableQueryEditor; + const timeRange = getTimeSrv().timeRange(); - let query = variable.query; - - if (typeof query === 'string') { - query = query || (datasource.variables?.getDefaultQuery?.() ?? ''); - } else { - query = { - ...datasource.variables?.getDefaultQuery?.(), - ...variable.query, - }; - } - - if (isLegacyQueryEditor(VariableQueryEditor, datasource)) { - return ( - - Query - - - - - ); - } - - const range = getTimeSrv().timeRange(); - - if (isQueryEditor(VariableQueryEditor, datasource)) { - return ( - - Query - - {}} - data={{ series: [], state: LoadingState.Done, timeRange: range }} - range={range} - onBlur={() => {}} - history={[]} - /> - - - ); - } - - return null; - }; - - render() { return ( - <> - Query options - - - - - {this.renderQueryEditor()} - - - 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 - - ). - - } - placeholder="/.*-(?.*)-(?.*)-.*/" - onChange={this.onRegExChange} - onBlur={this.onRegExBlur} - testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} - width={52} - /> - - - - - - Selection options - - + ); } } diff --git a/public/app/features/variables/query/QueryVariableRefreshSelect.tsx b/public/app/features/variables/query/QueryVariableRefreshSelect.tsx index 36c5500959d..88475bcc75e 100644 --- a/public/app/features/variables/query/QueryVariableRefreshSelect.tsx +++ b/public/app/features/variables/query/QueryVariableRefreshSelect.tsx @@ -7,6 +7,7 @@ import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; interface Props { onChange: (option: VariableRefresh) => void; refresh: VariableRefresh; + testId?: string; } const REFRESH_OPTIONS = [ @@ -14,7 +15,7 @@ const REFRESH_OPTIONS = [ { label: 'On time range change', value: VariableRefresh.onTimeRangeChanged }, ]; -export function QueryVariableRefreshSelect({ onChange, refresh }: PropsWithChildren) { +export function QueryVariableRefreshSelect({ onChange, refresh, testId }: PropsWithChildren) { const theme = useTheme2(); const [isSmallScreen, setIsSmallScreen] = useState(false); @@ -31,7 +32,7 @@ export function QueryVariableRefreshSelect({ onChange, refresh }: PropsWithChild ); return ( - + ) => void; sort: VariableSort; + testId?: string; } const SORT_OPTIONS = [ @@ -23,7 +23,7 @@ const SORT_OPTIONS = [ { label: 'Natural (desc)', value: VariableSort.naturalDesc }, ]; -export function QueryVariableSortSelect({ onChange, sort }: PropsWithChildren) { +export function QueryVariableSortSelect({ onChange, sort, testId }: PropsWithChildren) { const value = useMemo(() => SORT_OPTIONS.find((o) => o.value === sort) ?? SORT_OPTIONS[0], [sort]); return ( @@ -33,7 +33,7 @@ export function QueryVariableSortSelect({ onChange, sort }: PropsWithChildren );