Dashboard: Migration - EditVariable Settings: Implement DataSource Variable (#80885)

* Extract DatasourceVariableForm logic and use it in core grafana

* Implement DataSourceVariable editor in scenes

* Refactor VariableSelect and add unit test for DataSourceVariableEditor

* Refactor old unit test to use userEvent and mock getDataSourceSrv
This commit is contained in:
Alexa V 2024-01-25 12:56:37 +01:00 committed by GitHub
parent 0880a239f8
commit 2774e0d023
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 412 additions and 70 deletions

View File

@ -0,0 +1,79 @@
import React, { FormEvent } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SelectionOptionsForm } from './SelectionOptionsForm';
import { VariableLegend } from './VariableLegend';
import { VariableSelectField } from './VariableSelectField';
import { VariableTextField } from './VariableTextField';
interface DataSourceVariableFormProps {
query: string;
regex: string;
multi: boolean;
allValue?: string | null;
includeAll: boolean;
onChange: (option: SelectableValue) => void;
optionTypes: Array<{ value: string; label: string }>;
onRegExBlur: (event: FormEvent<HTMLInputElement>) => void;
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
}
export function DataSourceVariableForm({
query,
regex,
optionTypes,
onChange,
onRegExBlur,
multi,
includeAll,
allValue,
onMultiChange,
onIncludeAllChange,
onAllValueChange,
}: DataSourceVariableFormProps) {
const typeValue = optionTypes.find((o) => o.value === query) ?? optionTypes[0];
return (
<>
<VariableLegend>Data source options</VariableLegend>
<VariableSelectField
name="Type"
value={typeValue}
options={optionTypes}
onChange={onChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect}
/>
<VariableTextField
defaultValue={regex}
name="Instance name filter"
placeholder="/.*-(.*)-.*/"
onBlur={onRegExBlur}
description={
<div>
Regex filter for which data source instances to choose from in the variable value list. Leave empty for all.
<br />
<br />
Example: <code>/^prod/</code>
</div>
}
/>
<VariableLegend>Selection options</VariableLegend>
<SelectionOptionsForm
multi={multi}
includeAll={includeAll}
allValue={allValue}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
/>
</>
);
}

View File

@ -29,16 +29,15 @@ export function VariableSelectField({
return (
<Field label={name} description={description} htmlFor={inputId}>
<div data-testid={testId}>
<Select
inputId={inputId}
onChange={onChange}
value={value}
width={width ?? 30}
options={options}
className={styles.selectContainer}
/>
</div>
<Select
data-testid={testId}
inputId={inputId}
onChange={onChange}
value={value}
width={width ?? 30}
options={options}
className={styles.selectContainer}
/>
</Field>
);
}

View File

@ -0,0 +1,157 @@
// add unit test for the DataSourceVariableEditor component
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourceVariable } from '@grafana/scenes';
import { DataSourceVariableEditor } from './DataSourceVariableEditor';
//mock getDataSorceSrv.getList() to return a list of datasources
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getList: () => {
return [
{
name: 'DataSourceInstance1',
uid: 'ds1',
meta: {
name: 'ds1',
id: 'dsTestDataSource',
},
},
{
name: 'DataSourceInstance2',
uid: 'ds2',
meta: {
name: 'ds1',
id: 'dsTestDataSource',
},
},
{
name: 'ABCDataSourceInstance',
uid: 'ds3',
meta: {
name: 'abDS',
id: 'ABCDS',
},
},
];
},
}),
}));
describe('DataSourceVariableEditor', () => {
it('shoud render correctly with multi and all not checked', () => {
const variable = new DataSourceVariable({
name: 'dsVariable',
type: 'datasource',
label: 'Datasource',
pluginId: 'dsTestDataSource',
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const typeSelect = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect
);
expect(typeSelect).toBeInTheDocument();
expect(typeSelect.textContent).toBe('ds1');
expect(multiCheckbox).toBeInTheDocument();
expect(multiCheckbox).not.toBeChecked();
expect(includeAllCheckbox).toBeInTheDocument();
expect(includeAllCheckbox).not.toBeChecked();
});
it('shoud render correctly with multi and includeAll checked', () => {
const variable = new DataSourceVariable({
name: 'dsVariable',
type: 'datasource',
label: 'Datasource',
pluginId: 'dsTestDataSource',
isMulti: true,
includeAll: true,
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const typeSelect = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect
);
expect(typeSelect).toBeInTheDocument();
expect(typeSelect.textContent).toBe('ds1');
expect(multiCheckbox).toBeInTheDocument();
expect(multiCheckbox).toBeChecked();
expect(includeAllCheckbox).toBeInTheDocument();
expect(includeAllCheckbox).toBeChecked();
});
it('Should change type option when users select a different datasource type', async () => {
const variable = new DataSourceVariable({
name: 'dsVariable',
type: 'datasource',
label: 'Datasource',
pluginId: 'dsTestDataSource',
isMulti: false,
includeAll: false,
});
const onRunQuery = jest.fn();
const { getByTestId, user } = setup(<DataSourceVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const typeSelect = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect
);
// when user change type datasource
await user.click(typeSelect);
await user.type(typeSelect, 'abDS');
await user.keyboard('{enter}');
expect(typeSelect).toBeInTheDocument();
expect(typeSelect.textContent).toBe('abDS');
expect(onRunQuery).toHaveBeenCalledTimes(1);
// when user change checkbox multi
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
await user.click(multiCheckbox);
expect(multiCheckbox).toBeChecked();
// when user include all there is a new call to onRunQuery
await user.click(includeAllCheckbox);
expect(includeAllCheckbox).toBeChecked();
expect(onRunQuery).toHaveBeenCalledTimes(1);
});
});
// based on styleguide recomendation
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}

View File

@ -1,12 +1,62 @@
import React from 'react';
import React, { FormEvent } from 'react';
import { SelectableValue } from '@grafana/data';
import { DataSourceVariable } from '@grafana/scenes';
import { DataSourceVariableForm } from '../components/DataSourceVariableForm';
import { getOptionDataSourceTypes } from '../utils';
interface DataSourceVariableEditorProps {
variable: DataSourceVariable;
onChange: (variable: DataSourceVariable) => void;
onRunQuery: () => void;
}
export function DataSourceVariableEditor(props: DataSourceVariableEditorProps) {
return <div>DataSourceVariableEditor</div>;
export function DataSourceVariableEditor({ variable, onRunQuery }: DataSourceVariableEditorProps) {
const { pluginId, regex, isMulti, allValue, includeAll } = variable.useState();
const optionTypes = getOptionDataSourceTypes();
const onChangeType = (option: SelectableValue) => {
variable.setState({
pluginId: option.value,
});
onRunQuery();
};
const onRegExChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({
regex: event.currentTarget.value,
});
onRunQuery();
};
const onMultiChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({
isMulti: event.currentTarget.checked,
});
};
const onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ includeAll: event.currentTarget.checked });
};
const onAllValueChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ allValue: event.currentTarget.value });
};
return (
<DataSourceVariableForm
query={pluginId}
regex={regex}
multi={isMulti || false}
allValue={allValue}
includeAll={includeAll || false}
optionTypes={optionTypes}
onChange={onChangeType}
onRegExBlur={onRegExChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
/>
);
}

View File

@ -1,3 +1,4 @@
import { DataSourceApi } from '@grafana/data';
import { setTemplateSrv, TemplateSrv } from '@grafana/runtime';
import {
CustomVariable,
@ -8,7 +9,9 @@ import {
AdHocFiltersVariable,
TextBoxVariable,
} from '@grafana/scenes';
import { VariableType } from '@grafana/schema';
import { DataQuery, DataSourceJsonData, VariableType } from '@grafana/schema';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { AdHocFiltersVariableEditor } from './editors/AdHocFiltersVariableEditor';
import { ConstantVariableEditor } from './editors/ConstantVariableEditor';
@ -27,12 +30,44 @@ import {
hasVariableOptions,
EditableVariableType,
getDefinition,
getOptionDataSourceTypes,
} from './utils';
const templateSrv = {
getAdhocFilters: jest.fn().mockReturnValue([{ key: 'origKey', operator: '=', value: '' }]),
} as unknown as TemplateSrv;
const dsMock: DataSourceApi = {
meta: {
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
name: SHARED_DASHBOARD_QUERY,
type: SHARED_DASHBOARD_QUERY,
uid: SHARED_DASHBOARD_QUERY,
getRef: () => {
return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
get: async () => dsMock,
getList: () => {
return [
{
name: 'DataSourceInstance1',
uid: 'ds1',
meta: {
name: 'ds1',
id: 'dsTestDataSource',
},
},
];
},
}),
}));
describe('isEditableVariableType', () => {
it('should return true for editable variable types', () => {
const editableTypes: VariableType[] = ['custom', 'query', 'constant', 'interval', 'datasource', 'adhoc', 'textbox'];
@ -75,6 +110,10 @@ describe('getVariableTypeSelectOptions', () => {
});
describe('getVariableEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each(Object.keys(EDITABLE_VARIABLES) as EditableVariableType[])(
'should define an editor for every variable type',
(type) => {
@ -200,3 +239,13 @@ describe('getDefinition', () => {
expect(getDefinition(model)).toBe('Constant Value');
});
});
describe('getOptionDataSourceTypes', () => {
it('should return all data source types when no data source types are specified', () => {
const optionTypes = getOptionDataSourceTypes();
expect(optionTypes).toHaveLength(2);
// in the old code we always had an empty option
expect(optionTypes[0].value).toBe('');
expect(optionTypes[1].label).toBe('ds1');
});
});

View File

@ -1,4 +1,7 @@
import { SelectableValue } from '@grafana/data';
import { chain } from 'lodash';
import { DataSourceInstanceSettings, SelectableValue } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import {
ConstantVariable,
CustomVariable,
@ -140,3 +143,18 @@ export function getDefinition(model: SceneVariable): string {
return definition;
}
export function getOptionDataSourceTypes() {
const datasources = getDataSourceSrv().getList({ metrics: true, variables: true });
const optionTypes = chain(datasources)
.uniqBy('meta.id')
.map((ds: DataSourceInstanceSettings) => {
return { label: ds.meta.name, value: ds.meta.id };
})
.value();
optionTypes.unshift({ label: '', value: '' });
return optionTypes;
}

View File

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectOptionInTest, getSelectParent } from 'test/helpers/selectOptionInTest';
@ -45,10 +46,21 @@ describe('DataSourceVariableEditor', () => {
expect(field).toBeInTheDocument();
});
it('calls the handler when the regex filter is changed', () => {
render(<DataSourceVariableEditor {...props} />);
it('calls the handler when the regex filter is changed in onBlur', async () => {
const { user } = setup(<DataSourceVariableEditor {...props} />);
const field = screen.getByLabelText(/Instance name filter/);
fireEvent.change(field, { target: { value: '/prod/' } });
expect(props.onPropChange).toBeCalledWith({ propName: 'regex', propValue: '/prod/' });
await user.click(field);
await user.type(field, '/prod/');
expect(field).toHaveValue('/prod/');
await user.tab();
expect(props.onPropChange).toHaveBeenCalledWith({ propName: 'regex', propValue: '/prod/', updateOptions: true });
});
});
// based on styleguide recomendation
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}

View File

@ -2,19 +2,16 @@ import React, { FormEvent, PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourceVariableForm } from 'app/features/dashboard-scene/settings/variables/components/DataSourceVariableForm';
import { StoreState } from '../../../types';
import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend';
import { VariableSelectField } from '../../dashboard-scene/settings/variables/components/VariableSelectField';
import { VariableTextField } from '../../dashboard-scene/settings/variables/components/VariableTextField';
import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor';
import { initialVariableEditorState } from '../editor/reducer';
import { getDatasourceVariableEditorState } from '../editor/selectors';
import { OnPropChangeArguments, VariableEditorProps } from '../editor/types';
import { changeVariableMultiValue } from '../state/actions';
import { getVariablesState } from '../state/selectors';
import { DataSourceVariableModel, VariableWithMultiSupport } from '../types';
import { toKeyedVariableIdentifier } from '../utils';
import { initDataSourceVariableEditor } from './actions';
@ -57,13 +54,6 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> {
this.props.initDataSourceVariableEditor(rootStateKey);
}
onRegExChange = (event: FormEvent<HTMLInputElement>) => {
this.props.onPropChange({
propName: 'regex',
propValue: event.currentTarget.value,
});
};
onRegExBlur = (event: FormEvent<HTMLInputElement>) => {
this.props.onPropChange({
propName: 'regex',
@ -76,6 +66,18 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> {
this.props.onPropChange({ propName, propValue, updateOptions: true });
};
onMultiChanged = (event: FormEvent<HTMLInputElement>) => {
this.props.changeVariableMultiValue(toKeyedVariableIdentifier(this.props.variable), event.currentTarget.checked);
};
onIncludeAllChanged = (event: FormEvent<HTMLInputElement>) => {
this.onSelectionOptionsChange({ propName: 'includeAll', propValue: event.currentTarget.checked });
};
onAllValueChanged = (event: FormEvent<HTMLInputElement>) => {
this.onSelectionOptionsChange({ propName: 'allValue', propValue: event.currentTarget.value });
};
getSelectedDataSourceTypeValue = (): string => {
const { extended } = this.props;
@ -93,49 +95,25 @@ export class DataSourceVariableEditorUnConnected extends PureComponent<Props> {
};
render() {
const { variable, extended, changeVariableMultiValue } = this.props;
const { variable, extended } = this.props;
const typeOptions = extended?.dataSourceTypes?.length
? extended.dataSourceTypes?.map((ds) => ({ value: ds.value ?? '', label: ds.text }))
: [];
const typeValue = typeOptions.find((o) => o.value === variable.query) ?? typeOptions[0];
return (
<>
<VariableLegend>Data source options</VariableLegend>
<VariableSelectField
name="Type"
value={typeValue}
options={typeOptions}
onChange={this.onDataSourceTypeChanged}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect}
/>
<VariableTextField
value={this.props.variable.regex}
name="Instance name filter"
placeholder="/.*-(.*)-.*/"
onChange={this.onRegExChange}
onBlur={this.onRegExBlur}
description={
<div>
Regex filter for which data source instances to choose from in the variable value list. Leave empty for
all.
<br />
<br />
Example: <code>/^prod/</code>
</div>
}
/>
<VariableLegend>Selection options</VariableLegend>
<SelectionOptionsEditor
variable={variable}
onPropChange={this.onSelectionOptionsChange}
onMultiChanged={changeVariableMultiValue}
/>
</>
<DataSourceVariableForm
query={variable.query}
regex={variable.regex}
multi={variable.multi}
includeAll={variable.includeAll}
optionTypes={typeOptions}
onChange={this.onDataSourceTypeChanged}
onRegExBlur={this.onRegExBlur}
onMultiChange={this.onMultiChanged}
onIncludeAllChange={this.onIncludeAllChanged}
onAllValueChange={this.onAllValueChanged}
/>
);
}
}