Variables: replaces homegrown variableAdapters with Registry (#22866)

* Refactor: intial commit

* Tests: fixes tests

* Refactor: adds stricter typings
This commit is contained in:
Hugo Häggmark 2020-03-23 15:33:17 +01:00 committed by GitHub
parent 277edca3a0
commit cf5064bfa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 281 additions and 478 deletions

View File

@ -41,6 +41,7 @@ import { PerformanceBackend } from './core/services/echo/backends/PerformanceBac
import 'app/routes/GrafanaCtrl';
import 'app/features/all';
import { getStandardFieldConfigs } from '@grafana/ui';
import { getDefaultVariableAdapters, variableAdapters } from './features/variables/adapters';
// add move to lodash for backward compatabiltiy
// @ts-ignore
@ -84,6 +85,7 @@ export class GrafanaApp {
setMarkdownOptions({ sanitize: !config.disableSanitizeHtml });
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
variableAdapters.setInit(getDefaultVariableAdapters);
app.config(
(

View File

@ -188,7 +188,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
const list =
dashboard.variables.list.length > 0
? dashboard.variables.list
: dashboard.templating.list.filter(v => variableAdapters.contains(v.type));
: dashboard.templating.list.filter(v => variableAdapters.getIfExists(v.type));
await dispatch(initDashboardTemplating(list));
await dispatch(processVariables());
}

View File

@ -10,14 +10,6 @@ import { CustomVariable } from './custom_variable';
import { ConstantVariable } from './constant_variable';
import { AdhocVariable } from './adhoc_variable';
import { TextBoxVariable } from './TextBoxVariable';
import { variableAdapters } from '../variables/adapters';
import { createQueryVariableAdapter } from '../variables/query/adapter';
import { createCustomVariableAdapter } from '../variables/custom/adapter';
import { createTextBoxVariableAdapter } from '../variables/textbox/adapter';
import { createConstantVariableAdapter } from '../variables/constant/adapter';
import { createDataSourceVariableAdapter } from '../variables/datasource/adapter';
import { createAdHocVariableAdapter } from '../variables/adhoc/adapter';
import { createIntervalVariableAdapter } from '../variables/interval/adapter';
coreModule.factory('templateSrv', () => templateSrv);
@ -31,11 +23,3 @@ export {
AdhocVariable,
TextBoxVariable,
};
variableAdapters.set('query', createQueryVariableAdapter());
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
variableAdapters.set('datasource', createDataSourceVariableAdapter());
variableAdapters.set('adhoc', createAdHocVariableAdapter());
variableAdapters.set('interval', createIntervalVariableAdapter());

View File

@ -2,14 +2,34 @@ import { ComponentType } from 'react';
import { Reducer } from 'redux';
import { UrlQueryValue } from '@grafana/runtime';
import { VariableModel, VariableOption, VariableType } from '../templating/variable';
import {
AdHocVariableModel,
ConstantVariableModel,
CustomVariableModel,
DataSourceVariableModel,
IntervalVariableModel,
QueryVariableModel,
TextBoxVariableModel,
VariableModel,
VariableOption,
VariableType,
} from '../templating/variable';
import { VariableEditorProps } from './editor/types';
import { VariablesState } from './state/variablesReducer';
import { VariablePickerProps } from './pickers/types';
import { Registry } from '@grafana/data';
import { createQueryVariableAdapter } from './query/adapter';
import { createCustomVariableAdapter } from './custom/adapter';
import { createTextBoxVariableAdapter } from './textbox/adapter';
import { createConstantVariableAdapter } from './constant/adapter';
import { createDataSourceVariableAdapter } from './datasource/adapter';
import { createIntervalVariableAdapter } from './interval/adapter';
import { createAdHocVariableAdapter } from './adhoc/adapter';
export interface VariableAdapter<Model extends VariableModel> {
id: VariableType;
description: string;
label: string;
name: string;
initialState: Model;
dependsOn: (variable: Model, variableToTest: Model) => boolean;
setValue: (variable: Model, option: VariableOption, emitChanges?: boolean) => Promise<void>;
@ -22,40 +42,24 @@ export interface VariableAdapter<Model extends VariableModel> {
reducer: Reducer<VariablesState>;
}
const allVariableAdapters: Record<VariableType, VariableAdapter<any> | null> = {
interval: null,
query: null,
datasource: null,
custom: null,
constant: null,
adhoc: null,
textbox: null,
};
export type VariableModels =
| QueryVariableModel
| CustomVariableModel
| TextBoxVariableModel
| ConstantVariableModel
| DataSourceVariableModel
| IntervalVariableModel
| AdHocVariableModel;
export type VariableTypeRegistry<Model extends VariableModel = VariableModel> = Registry<VariableAdapter<Model>>;
export interface VariableAdapters {
contains: (type: VariableType) => boolean;
get: (type: VariableType) => VariableAdapter<any>;
set: (type: VariableType, adapter: VariableAdapter<any>) => void;
registeredTypes: () => Array<{ type: VariableType; label: string }>;
}
export const getDefaultVariableAdapters = () => [
createQueryVariableAdapter(),
createCustomVariableAdapter(),
createTextBoxVariableAdapter(),
createConstantVariableAdapter(),
createDataSourceVariableAdapter(),
createIntervalVariableAdapter(),
createAdHocVariableAdapter(),
];
export const variableAdapters: VariableAdapters = {
contains: (type: VariableType): boolean => !!allVariableAdapters[type],
get: (type: VariableType): VariableAdapter<any> => {
if (allVariableAdapters[type] !== null) {
// @ts-ignore
// Suppressing strict null check in this case we know that this is an instance otherwise we throw
// Type 'VariableAdapter<any, any> | null' is not assignable to type 'VariableAdapter<any, any>'.
// Type 'null' is not assignable to type 'VariableAdapter<any, any>'.
return allVariableAdapters[type];
}
throw new Error(`There is no adapter for type:${type}`);
},
set: (type, adapter) => (allVariableAdapters[type] = adapter),
registeredTypes: (): Array<{ type: VariableType; label: string }> => {
return Object.keys(allVariableAdapters)
.filter((key: VariableType) => allVariableAdapters[key] !== null)
.map((key: VariableType) => ({ type: key, label: allVariableAdapters[key]!.label }));
},
};
export const variableAdapters: VariableTypeRegistry = new Registry<VariableAdapter<VariableModels>>();

View File

@ -40,9 +40,9 @@ type ReducersUsedInContext = {
location: LocationState;
};
describe('adhoc actions', () => {
variableAdapters.set('adhoc', createAdHocVariableAdapter());
variableAdapters.setInit(() => [createAdHocVariableAdapter()]);
describe('adhoc actions', () => {
describe('when applyFilterFromTable is dispatched and filter already exist', () => {
it('then correct actions are dispatched', async () => {
const options: AdHocTableOptions = {

View File

@ -12,8 +12,9 @@ const noop = async () => {};
export const createAdHocVariableAdapter = (): VariableAdapter<AdHocVariableModel> => {
return {
id: 'adhoc',
description: 'Add key/value filters on the fly',
label: 'Ad hoc filters',
name: 'Ad hoc filters',
initialState: initialAdHocVariableModelState,
reducer: adHocVariableReducer,
picker: AdHocPicker,

View File

@ -11,7 +11,7 @@ import { setCurrentVariableValue } from '../state/sharedReducer';
import { initDashboardTemplating } from '../state/actions';
describe('constant actions', () => {
variableAdapters.set('constant', createConstantVariableAdapter());
variableAdapters.setInit(() => [createConstantVariableAdapter()]);
describe('when updateConstantVariableOptions is dispatched', () => {
it('then correct actions are dispatched', async () => {

View File

@ -11,8 +11,9 @@ import { toVariableIdentifier } from '../state/types';
export const createConstantVariableAdapter = (): VariableAdapter<ConstantVariableModel> => {
return {
id: 'constant',
description: 'Define a hidden constant variable, useful for metric prefixes in dashboards you want to share',
label: 'Constant',
name: 'Constant',
initialState: initialConstantVariableModelState,
reducer: constantVariableReducer,
picker: OptionsPicker,

View File

@ -11,7 +11,7 @@ import { TemplatingState } from '../state/reducers';
import { createCustomOptionsFromQuery } from './reducer';
describe('custom actions', () => {
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.setInit(() => [createCustomVariableAdapter()]);
describe('when updateCustomVariableOptions is dispatched', () => {
it('then correct actions are dispatched', async () => {

View File

@ -11,8 +11,9 @@ import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types';
export const createCustomVariableAdapter = (): VariableAdapter<CustomVariableModel> => {
return {
id: 'custom',
description: 'Define variable values manually',
label: 'Custom',
name: 'Custom',
initialState: initialCustomVariableModelState,
reducer: customVariableReducer,
picker: OptionsPicker,

View File

@ -18,7 +18,7 @@ import { changeVariableEditorExtended } from '../editor/reducer';
import { datasourceBuilder } from '../shared/testing/builders';
describe('data source actions', () => {
variableAdapters.set('datasource', createDataSourceVariableAdapter());
variableAdapters.setInit(() => [createDataSourceVariableAdapter()]);
describe('when updateDataSourceVariableOptions is dispatched', () => {
describe('and there is no regex', () => {

View File

@ -11,8 +11,9 @@ import { updateDataSourceVariableOptions } from './actions';
export const createDataSourceVariableAdapter = (): VariableAdapter<DataSourceVariableModel> => {
return {
id: 'datasource',
description: 'Enabled you to dynamically switch the datasource for multiple panels',
label: 'Datasource',
name: 'Datasource',
initialState: initialDataSourceVariableModelState,
reducer: dataSourceVariableReducer,
picker: OptionsPicker,

View File

@ -143,9 +143,9 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
onChange={this.onTypeChange}
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalTypeSelect}
>
{variableAdapters.registeredTypes().map(item => (
<option key={item.type} label={item.label} value={item.type}>
{item.label}
{variableAdapters.list().map(({ id, name }) => (
<option key={id} label={name} value={id}>
{name}
</option>
))}
</select>

View File

@ -20,7 +20,7 @@ import { TemplateSrv } from '../../templating/template_srv';
import { intervalBuilder } from '../shared/testing/builders';
describe('interval actions', () => {
variableAdapters.set('interval', createIntervalVariableAdapter());
variableAdapters.setInit(() => [createIntervalVariableAdapter()]);
describe('when updateIntervalVariableOptions is dispatched', () => {
it('then correct actions are dispatched', async () => {
const interval = intervalBuilder()

View File

@ -11,8 +11,9 @@ import { updateAutoValue, updateIntervalVariableOptions } from './actions';
export const createIntervalVariableAdapter = (): VariableAdapter<IntervalVariableModel> => {
return {
id: 'interval',
description: 'Define a timespan interval (ex 1m, 1h, 1d)',
label: 'Interval',
name: 'Interval',
initialState: initialIntervalVariableModelState,
reducer: intervalVariableReducer,
picker: OptionsPicker,

View File

@ -40,7 +40,7 @@ jest.mock('@grafana/runtime', () => {
});
describe('options picker actions', () => {
variableAdapters.set('query', createQueryVariableAdapter());
variableAdapters.setInit(() => [createQueryVariableAdapter()]);
describe('when navigateOptions is dispatched with navigation key cancel', () => {
it('then correct actions are dispatched', async () => {

View File

@ -47,7 +47,7 @@ jest.mock('../../plugins/plugin_loader', () => ({
}));
describe('query actions', () => {
variableAdapters.set('query', createQueryVariableAdapter());
variableAdapters.setInit(() => [createQueryVariableAdapter()]);
describe('when updateQueryVariableOptions is dispatched for variable with tags and includeAll', () => {
it('then correct actions are dispatched', async () => {

View File

@ -12,8 +12,9 @@ import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types';
export const createQueryVariableAdapter = (): VariableAdapter<QueryVariableModel> => {
return {
id: 'query',
description: 'Variable values are fetched from a datasource query',
label: 'Query',
name: 'Query',
initialState: initialQueryVariableModelState,
reducer: queryVariableReducer,
picker: OptionsPicker,

View File

@ -1,6 +1,5 @@
import { AnyAction } from 'redux';
import { UrlQueryMap } from '@grafana/runtime';
import { dateTime, TimeRange } from '@grafana/data';
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer } from './helpers';
import { variableAdapters } from '../adapters';
@ -8,49 +7,29 @@ import { createQueryVariableAdapter } from '../query/adapter';
import { createCustomVariableAdapter } from '../custom/adapter';
import { createTextBoxVariableAdapter } from '../textbox/adapter';
import { createConstantVariableAdapter } from '../constant/adapter';
import { createIntervalVariableAdapter } from '../interval/adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from 'app/features/variables/state/reducers';
import {
initDashboardTemplating,
onTimeRangeUpdated,
OnTimeRangeUpdatedDependencies,
processVariables,
setOptionFromUrl,
validateVariableSelectionState,
} from './actions';
import {
addInitLock,
addVariable,
removeInitLock,
removeVariable,
resolveInitLock,
setCurrentVariableValue,
} from './sharedReducer';
import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload } from './types';
import { changeVariableName } from '../editor/actions';
import { changeVariableNameFailed, changeVariableNameSucceeded, setIdInEditor } from '../editor/reducer';
import { TemplateSrv } from '../../templating/template_srv';
import { Emitter } from '../../../core/core';
import { VariableRefresh } from '../../templating/variable';
import { DashboardModel } from '../../dashboard/state';
import { DashboardState } from '../../../types';
import { initDashboardTemplating, processVariables, setOptionFromUrl, validateVariableSelectionState } from './actions';
import { addInitLock, addVariable, removeInitLock, resolveInitLock, setCurrentVariableValue } from './sharedReducer';
import { toVariableIdentifier, toVariablePayload } from './types';
import {
constantBuilder,
customBuilder,
datasourceBuilder,
intervalBuilder,
queryBuilder,
textboxBuilder,
} from '../shared/testing/builders';
variableAdapters.setInit(() => [
createQueryVariableAdapter(),
createCustomVariableAdapter(),
createTextBoxVariableAdapter(),
createConstantVariableAdapter(),
]);
describe('shared actions', () => {
describe('when initDashboardTemplating is dispatched', () => {
it('then correct actions are dispatched', () => {
variableAdapters.set('query', createQueryVariableAdapter());
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
const query = queryBuilder().build();
const constant = constantBuilder().build();
const datasource = datasourceBuilder().build();
@ -98,10 +77,6 @@ describe('shared actions', () => {
describe('when processVariables is dispatched', () => {
it('then correct actions are dispatched', async () => {
variableAdapters.set('query', createQueryVariableAdapter());
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
const query = queryBuilder().build();
const constant = constantBuilder().build();
const datasource = datasourceBuilder().build();
@ -161,7 +136,6 @@ describe('shared actions', () => {
${null} | ${[null]}
${undefined} | ${[undefined]}
`('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected }) => {
variableAdapters.set('custom', createCustomVariableAdapter());
const custom = customBuilder()
.withId('0')
.withOptions('A', 'B', 'C')
@ -195,7 +169,6 @@ describe('shared actions', () => {
${['A', 'B', 'C']} | ${'X'} | ${'C'} | ${'C'}
${undefined} | ${'B'} | ${undefined} | ${'A'}
`('then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expected }) => {
variableAdapters.set('custom', createCustomVariableAdapter());
let custom;
if (!withOptions) {
@ -249,7 +222,6 @@ describe('shared actions', () => {
`(
'then correct actions are dispatched',
async ({ withOptions, withCurrent, defaultValue, expectedText, expectedSelected }) => {
variableAdapters.set('custom', createCustomVariableAdapter());
let custom;
if (!withOptions) {
@ -294,311 +266,4 @@ describe('shared actions', () => {
);
});
});
describe('when onTimeRangeUpdated is dispatched', () => {
const getOnTimeRangeUpdatedContext = (args: { update?: boolean; throw?: boolean }) => {
const range: TimeRange = {
from: dateTime(new Date().getTime()).subtract(1, 'minutes'),
to: dateTime(new Date().getTime()),
raw: {
from: 'now-1m',
to: 'now',
},
};
const updateTimeRangeMock = jest.fn();
const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv;
const emitMock = jest.fn();
const appEventsMock = ({ emit: emitMock } as unknown) as Emitter;
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, appEvents: appEventsMock };
const templateVariableValueUpdatedMock = jest.fn();
const dashboard = ({
getModel: () =>
(({
templateVariableValueUpdated: templateVariableValueUpdatedMock,
startRefresh: startRefreshMock,
} as unknown) as DashboardModel),
} as unknown) as DashboardState;
const startRefreshMock = jest.fn();
const adapter = createIntervalVariableAdapter();
adapter.updateOptions = args.throw
? jest.fn().mockRejectedValue('Something broke')
: jest.fn().mockResolvedValue({});
variableAdapters.set('interval', adapter);
variableAdapters.set('constant', createConstantVariableAdapter());
// initial variable state
const initialVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
// the constant variable should be filtered out
const constant = constantBuilder()
.withId('constant-1')
.withName('constant-1')
.withOptions('a constant')
.withCurrent('a constant')
.build();
const initialState = {
templating: { variables: { 'interval-0': { ...initialVariable }, 'constant-1': { ...constant } } },
dashboard,
};
// updated variable state
const updatedVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
const state = { templating: { variables: { 'interval-0': variable, 'constant-1': { ...constant } } }, dashboard };
const getStateMock = jest
.fn()
.mockReturnValueOnce(initialState)
.mockReturnValue(state);
const dispatchMock = jest.fn();
return {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
};
};
describe('and options are changed by update', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(4);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
});
});
describe('and options are not changed by update', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: false });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(3);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
});
});
describe('and updateOptions throws', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: false, throw: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(0);
expect(emitMock).toHaveBeenCalledTimes(1);
});
});
});
describe('when changeVariableName is dispatched with the same name', () => {
it('then no actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId('constant')
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), constant.name), true)
.thenNoActionsWhereDispatched();
});
});
describe('when changeVariableName is dispatched with an unique name', () => {
it('then the correct actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId('constant')
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true)
.thenDispatchedActionsShouldEqual(
addVariable({
type: 'constant',
id: 'constant1',
data: {
global: false,
index: 1,
model: { ...constant, name: 'constant1', id: 'constant1', global: false, index: 1 },
},
}),
changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }),
setIdInEditor({ id: 'constant1' }),
removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } })
);
});
});
describe('when changeVariableName is dispatched with an unique name for a new variable', () => {
it('then the correct actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId(NEW_VARIABLE_ID)
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true)
.thenDispatchedActionsShouldEqual(
changeVariableNameSucceeded({ type: 'constant', id: NEW_VARIABLE_ID, data: { newName: 'constant1' } })
);
});
});
describe('when changeVariableName is dispatched with __newName', () => {
it('then the correct actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId('constant')
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '__newName'), true)
.thenDispatchedActionsShouldEqual(
changeVariableNameFailed({
newName: '__newName',
errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables",
})
);
});
});
describe('when changeVariableName is dispatched with illegal characters', () => {
it('then the correct actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId('constant')
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '#constant!'), true)
.thenDispatchedActionsShouldEqual(
changeVariableNameFailed({
newName: '#constant!',
errorText: 'Only word and digit characters are allowed in variable names',
})
);
});
});
describe('when changeVariableName is dispatched with a name that is already used', () => {
it('then the correct actions are dispatched', () => {
const textbox = textboxBuilder()
.withId('textbox')
.withName('textbox')
.build();
const constant = constantBuilder()
.withId('constant')
.withName('constant')
.build();
reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox })))
.whenActionIsDispatched(addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })))
.whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'textbox'), true)
.thenDispatchedActionsShouldEqual(
changeVariableNameFailed({
newName: 'textbox',
errorText: 'Variable with the same name already exists',
})
);
});
});
});

View File

@ -55,7 +55,7 @@ export const initDashboardTemplating = (list: VariableModel[]): ThunkResult<void
let orderIndex = 0;
for (let index = 0; index < list.length; index++) {
const model = list[index];
if (!variableAdapters.contains(model.type)) {
if (!variableAdapters.getIfExists(model.type)) {
continue;
}
@ -76,7 +76,7 @@ export const processVariableDependencies = async (variable: VariableModel, state
continue;
}
if (variableAdapters.contains(variable.type)) {
if (variableAdapters.getIfExists(variable.type)) {
if (variableAdapters.get(variable.type).dependsOn(variable, otherVariable)) {
dependencies.push(otherVariable.initLock!.promise);
}

View File

@ -0,0 +1,166 @@
import { dateTime, TimeRange } from '@grafana/data';
import { TemplateSrv } from '../../templating/template_srv';
import { Emitter } from '../../../core/utils/emitter';
import { onTimeRangeUpdated, OnTimeRangeUpdatedDependencies } from './actions';
import { DashboardModel } from '../../dashboard/state';
import { DashboardState } from '../../../types';
import { createIntervalVariableAdapter } from '../interval/adapter';
import { variableAdapters } from '../adapters';
import { createConstantVariableAdapter } from '../constant/adapter';
import { VariableRefresh } from '../../templating/variable';
import { constantBuilder, intervalBuilder } from '../shared/testing/builders';
variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]);
const getOnTimeRangeUpdatedContext = (args: { update?: boolean; throw?: boolean }) => {
const range: TimeRange = {
from: dateTime(new Date().getTime()).subtract(1, 'minutes'),
to: dateTime(new Date().getTime()),
raw: {
from: 'now-1m',
to: 'now',
},
};
const updateTimeRangeMock = jest.fn();
const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv;
const emitMock = jest.fn();
const appEventsMock = ({ emit: emitMock } as unknown) as Emitter;
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, appEvents: appEventsMock };
const templateVariableValueUpdatedMock = jest.fn();
const dashboard = ({
getModel: () =>
(({
templateVariableValueUpdated: templateVariableValueUpdatedMock,
startRefresh: startRefreshMock,
} as unknown) as DashboardModel),
} as unknown) as DashboardState;
const startRefreshMock = jest.fn();
const adapter = variableAdapters.get('interval');
adapter.updateOptions = args.throw ? jest.fn().mockRejectedValue('Something broke') : jest.fn().mockResolvedValue({});
// initial variable state
const initialVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
// the constant variable should be filtered out
const constant = constantBuilder()
.withId('constant-1')
.withName('constant-1')
.withOptions('a constant')
.withCurrent('a constant')
.build();
const initialState = {
templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } },
dashboard,
};
// updated variable state
const updatedVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
const state = { templating: { variables: { 'interval-0': variable, 'constant-1': { ...constant } } }, dashboard };
const getStateMock = jest
.fn()
.mockReturnValueOnce(initialState)
.mockReturnValue(state);
const dispatchMock = jest.fn();
return {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
};
};
describe('when onTimeRangeUpdated is dispatched', () => {
describe('and options are changed by update', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(4);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
});
});
describe('and options are not changed by update', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: false });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(3);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
});
});
describe('and updateOptions throws', () => {
it('then correct dependencies are called', async () => {
const {
range,
dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
emitMock,
} = getOnTimeRangeUpdatedContext({ update: false, throw: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(0);
expect(emitMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -62,14 +62,14 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
}),
}));
variableAdapters.setInit(() => [createCustomVariableAdapter(), createQueryVariableAdapter()]);
describe('processVariable', () => {
// these following processVariable tests will test the following base setup
// custom doesn't depend on any other variable
// queryDependsOnCustom depends on custom
// queryNoDepends doesn't depend on any other variable
const getAndSetupProcessVariableContext = () => {
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('query', createQueryVariableAdapter());
const custom = customBuilder()
.withId('custom')
.withName('custom')

View File

@ -1,11 +1,29 @@
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { cleanUpDashboard } from 'app/features/dashboard/state/reducers';
import { VariableHide, VariableModel } from '../../templating/variable';
import { QueryVariableModel, VariableHide, VariableType } from '../../templating/variable';
import { VariableAdapter, variableAdapters } from '../adapters';
import { createAction } from '@reduxjs/toolkit';
import { variablesReducer, VariablesState } from './variablesReducer';
import { toVariablePayload, VariablePayload } from './types';
const variableAdapter: VariableAdapter<QueryVariableModel> = {
id: ('mock' as unknown) as VariableType,
name: 'Mock label',
description: 'Mock description',
dependsOn: jest.fn(),
updateOptions: jest.fn(),
initialState: {} as QueryVariableModel,
reducer: jest.fn().mockReturnValue({}),
getValueForUrl: jest.fn(),
getSaveModel: jest.fn(),
picker: null as any,
editor: null as any,
setValue: jest.fn(),
setValueFromUrl: jest.fn(),
};
variableAdapters.setInit(() => [{ ...variableAdapter }]);
describe('variablesReducer', () => {
describe('when cleanUpDashboard is dispatched', () => {
it('then all variables except global variables should be removed', () => {
@ -91,30 +109,16 @@ describe('variablesReducer', () => {
skipUrlSync: false,
},
};
const variableAdapter: VariableAdapter<VariableModel> = {
label: 'Mock label',
description: 'Mock description',
dependsOn: jest.fn(),
updateOptions: jest.fn(),
initialState: {} as VariableModel,
reducer: jest.fn().mockReturnValue(initialState),
getValueForUrl: jest.fn(),
getSaveModel: jest.fn(),
picker: null as any,
editor: null as any,
setValue: jest.fn(),
setValueFromUrl: jest.fn(),
};
variableAdapters.set('query', variableAdapter);
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
const mockAction = createAction<VariablePayload>('mockAction');
reducerTester<VariablesState>()
.givenReducer(variablesReducer, initialState)
.whenActionIsDispatched(mockAction(toVariablePayload({ type: 'query', id: '0' })))
.whenActionIsDispatched(mockAction(toVariablePayload({ type: ('mock' as unknown) as VariableType, id: '0' })))
.thenStateShouldEqual(initialState);
expect(variableAdapter.reducer).toHaveBeenCalledTimes(1);
expect(variableAdapter.reducer).toHaveBeenCalledWith(
expect(variableAdapters.get('mock').reducer).toHaveBeenCalledTimes(1);
expect(variableAdapters.get('mock').reducer).toHaveBeenCalledWith(
initialState,
mockAction(toVariablePayload({ type: 'query', id: '0' }))
mockAction(toVariablePayload({ type: ('mock' as unknown) as VariableType, id: '0' }))
);
});
});
@ -132,27 +136,13 @@ describe('variablesReducer', () => {
skipUrlSync: false,
},
};
const variableAdapter: VariableAdapter<VariableModel> = {
label: 'Mock label',
description: 'Mock description',
dependsOn: jest.fn(),
updateOptions: jest.fn(),
initialState: {} as VariableModel,
reducer: jest.fn().mockReturnValue(initialState),
getValueForUrl: jest.fn(),
getSaveModel: jest.fn(),
picker: null as any,
editor: null as any,
setValue: jest.fn(),
setValueFromUrl: jest.fn(),
};
variableAdapters.set('query', variableAdapter);
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
const mockAction = createAction<VariablePayload>('mockAction');
reducerTester<VariablesState>()
.givenReducer(variablesReducer, initialState)
.whenActionIsDispatched(mockAction(toVariablePayload({ type: 'adhoc', id: '0' })))
.thenStateShouldEqual(initialState);
expect(variableAdapter.reducer).toHaveBeenCalledTimes(0);
expect(variableAdapters.get('mock').reducer).toHaveBeenCalledTimes(0);
});
});
@ -169,27 +159,13 @@ describe('variablesReducer', () => {
skipUrlSync: false,
},
};
const variableAdapter: VariableAdapter<VariableModel> = {
label: 'Mock label',
description: 'Mock description',
dependsOn: jest.fn(),
updateOptions: jest.fn(),
initialState: {} as VariableModel,
reducer: jest.fn().mockReturnValue(initialState),
getValueForUrl: jest.fn(),
getSaveModel: jest.fn(),
picker: null as any,
editor: null as any,
setValue: jest.fn(),
setValueFromUrl: jest.fn(),
};
variableAdapters.set('query', variableAdapter);
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
const mockAction = createAction<string>('mockAction');
reducerTester<VariablesState>()
.givenReducer(variablesReducer, initialState)
.whenActionIsDispatched(mockAction('mocked'))
.thenStateShouldEqual(initialState);
expect(variableAdapter.reducer).toHaveBeenCalledTimes(0);
expect(variableAdapters.get('mock').reducer).toHaveBeenCalledTimes(0);
});
});
});

View File

@ -24,12 +24,13 @@ import { getVariableState, getVariableTestContext } from './helpers';
import { initialVariablesState, VariablesState } from './variablesReducer';
import { changeVariableNameSucceeded } from '../editor/reducer';
variableAdapters.setInit(() => [createQueryVariableAdapter()]);
describe('sharedReducer', () => {
describe('when addVariable is dispatched', () => {
it('then state should be correct', () => {
const model = ({ name: 'name from model', type: 'type from model' } as unknown) as QueryVariableModel;
const payload = toVariablePayload({ id: '0', type: 'query' }, { global: true, index: 0, model });
variableAdapters.set('query', createQueryVariableAdapter());
reducerTester<VariablesState>()
.givenReducer(sharedReducer, { ...initialVariablesState })
.whenActionIsDispatched(addVariable(payload))
@ -107,7 +108,6 @@ describe('sharedReducer', () => {
describe('when duplicateVariable is dispatched', () => {
it('then state should be correct', () => {
variableAdapters.set('query', createQueryVariableAdapter());
const initialState: VariablesState = getVariableState(3);
const payload = toVariablePayload({ id: '1', type: 'query' }, { newId: '11' });
reducerTester<VariablesState>()
@ -193,7 +193,6 @@ describe('sharedReducer', () => {
describe('when storeNewVariable is dispatched', () => {
it('then state should be correct', () => {
variableAdapters.set('query', createQueryVariableAdapter());
const initialState: VariablesState = getVariableState(3, -1, true);
const payload = toVariablePayload({ id: '11', type: 'query' });
reducerTester<VariablesState>()

View File

@ -27,7 +27,7 @@ export const variablesReducer = (
return variables;
}
if (action?.payload?.type && variableAdapters.contains(action?.payload?.type)) {
if (action?.payload?.type && variableAdapters.getIfExists(action?.payload?.type)) {
// Now that we know we are dealing with a payload that is addressed for an adapted variable let's reduce state:
// Firstly call the sharedTemplatingReducer that handles all shared actions between variable types
// Secondly call the specific variable type's reducer

View File

@ -11,7 +11,7 @@ import { setCurrentVariableValue } from '../state/sharedReducer';
import { initDashboardTemplating } from '../state/actions';
describe('textbox actions', () => {
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.setInit(() => [createTextBoxVariableAdapter()]);
describe('when updateTextBoxVariableOptions is dispatched', () => {
it('then correct actions are dispatched', async () => {

View File

@ -12,8 +12,9 @@ import { toVariableIdentifier } from '../state/types';
export const createTextBoxVariableAdapter = (): VariableAdapter<TextBoxVariableModel> => {
return {
id: 'textbox',
description: 'Define a textbox variable, where users can enter any arbitrary string',
label: 'Text box',
name: 'Text box',
initialState: initialTextBoxVariableModelState,
reducer: textBoxVariableReducer,
picker: TextBoxVariablePicker,