diff --git a/public/app/app.ts b/public/app/app.ts index 87a7bd0970a..d1ba7518b9f 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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( ( diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 45f59d4c382..b21a15e769e 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -188,7 +188,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { 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()); } diff --git a/public/app/features/templating/all.ts b/public/app/features/templating/all.ts index 9448bb1c25e..9431c3d036c 100644 --- a/public/app/features/templating/all.ts +++ b/public/app/features/templating/all.ts @@ -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()); diff --git a/public/app/features/variables/adapters.ts b/public/app/features/variables/adapters.ts index 3f25beef17d..a9bfc21e40a 100644 --- a/public/app/features/variables/adapters.ts +++ b/public/app/features/variables/adapters.ts @@ -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 { + id: VariableType; description: string; - label: string; + name: string; initialState: Model; dependsOn: (variable: Model, variableToTest: Model) => boolean; setValue: (variable: Model, option: VariableOption, emitChanges?: boolean) => Promise; @@ -22,40 +42,24 @@ export interface VariableAdapter { reducer: Reducer; } -const allVariableAdapters: Record | 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 = Registry>; -export interface VariableAdapters { - contains: (type: VariableType) => boolean; - get: (type: VariableType) => VariableAdapter; - set: (type: VariableType, adapter: VariableAdapter) => void; - registeredTypes: () => Array<{ type: VariableType; label: string }>; -} - -export const variableAdapters: VariableAdapters = { - contains: (type: VariableType): boolean => !!allVariableAdapters[type], - get: (type: VariableType): VariableAdapter => { - 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 | null' is not assignable to type 'VariableAdapter'. - // Type 'null' is not assignable to type 'VariableAdapter'. - return allVariableAdapters[type]; - } +export const getDefaultVariableAdapters = () => [ + createQueryVariableAdapter(), + createCustomVariableAdapter(), + createTextBoxVariableAdapter(), + createConstantVariableAdapter(), + createDataSourceVariableAdapter(), + createIntervalVariableAdapter(), + createAdHocVariableAdapter(), +]; - 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>(); diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index f57e089e8e7..c28cfea774a 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -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 = { diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts index 9849b5457d0..b5a3cd5d12e 100644 --- a/public/app/features/variables/adhoc/adapter.ts +++ b/public/app/features/variables/adhoc/adapter.ts @@ -12,8 +12,9 @@ const noop = async () => {}; export const createAdHocVariableAdapter = (): VariableAdapter => { 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, diff --git a/public/app/features/variables/constant/actions.test.ts b/public/app/features/variables/constant/actions.test.ts index 019a3d0823e..ad4551ce4e8 100644 --- a/public/app/features/variables/constant/actions.test.ts +++ b/public/app/features/variables/constant/actions.test.ts @@ -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 () => { diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index e31061360ed..3dd2e13f1f4 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -11,8 +11,9 @@ import { toVariableIdentifier } from '../state/types'; export const createConstantVariableAdapter = (): VariableAdapter => { 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, diff --git a/public/app/features/variables/custom/actions.test.ts b/public/app/features/variables/custom/actions.test.ts index 65424eb3d48..305b631df30 100644 --- a/public/app/features/variables/custom/actions.test.ts +++ b/public/app/features/variables/custom/actions.test.ts @@ -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 () => { diff --git a/public/app/features/variables/custom/adapter.ts b/public/app/features/variables/custom/adapter.ts index 50bd28ecbd6..0f40bc3f60f 100644 --- a/public/app/features/variables/custom/adapter.ts +++ b/public/app/features/variables/custom/adapter.ts @@ -11,8 +11,9 @@ import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types'; export const createCustomVariableAdapter = (): VariableAdapter => { return { + id: 'custom', description: 'Define variable values manually', - label: 'Custom', + name: 'Custom', initialState: initialCustomVariableModelState, reducer: customVariableReducer, picker: OptionsPicker, diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index 6d9d8a0996e..f7e68ec24f8 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -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', () => { diff --git a/public/app/features/variables/datasource/adapter.ts b/public/app/features/variables/datasource/adapter.ts index d8b05bd8ee3..a03ca9a8704 100644 --- a/public/app/features/variables/datasource/adapter.ts +++ b/public/app/features/variables/datasource/adapter.ts @@ -11,8 +11,9 @@ import { updateDataSourceVariableOptions } from './actions'; export const createDataSourceVariableAdapter = (): VariableAdapter => { return { + id: 'datasource', description: 'Enabled you to dynamically switch the datasource for multiple panels', - label: 'Datasource', + name: 'Datasource', initialState: initialDataSourceVariableModelState, reducer: dataSourceVariableReducer, picker: OptionsPicker, diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index 719ea26036e..e5868dc8f8e 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -143,9 +143,9 @@ export class VariableEditorEditorUnConnected extends PureComponent { onChange={this.onTypeChange} aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalTypeSelect} > - {variableAdapters.registeredTypes().map(item => ( - ))} diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 1f8a1719cb7..973f7862c6f 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -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() diff --git a/public/app/features/variables/interval/adapter.ts b/public/app/features/variables/interval/adapter.ts index 9228fb272f8..a38f28126d1 100644 --- a/public/app/features/variables/interval/adapter.ts +++ b/public/app/features/variables/interval/adapter.ts @@ -11,8 +11,9 @@ import { updateAutoValue, updateIntervalVariableOptions } from './actions'; export const createIntervalVariableAdapter = (): VariableAdapter => { return { + id: 'interval', description: 'Define a timespan interval (ex 1m, 1h, 1d)', - label: 'Interval', + name: 'Interval', initialState: initialIntervalVariableModelState, reducer: intervalVariableReducer, picker: OptionsPicker, diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.test.ts b/public/app/features/variables/pickers/OptionsPicker/actions.test.ts index 2ab8b2f40a8..f044ec1ce9c 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.test.ts @@ -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 () => { diff --git a/public/app/features/variables/query/actions.test.ts b/public/app/features/variables/query/actions.test.ts index 8133f8c630c..927258576ac 100644 --- a/public/app/features/variables/query/actions.test.ts +++ b/public/app/features/variables/query/actions.test.ts @@ -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 () => { diff --git a/public/app/features/variables/query/adapter.ts b/public/app/features/variables/query/adapter.ts index abba5da0025..6949c01fcdf 100644 --- a/public/app/features/variables/query/adapter.ts +++ b/public/app/features/variables/query/adapter.ts @@ -12,8 +12,9 @@ import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types'; export const createQueryVariableAdapter = (): VariableAdapter => { return { + id: 'query', description: 'Variable values are fetched from a datasource query', - label: 'Query', + name: 'Query', initialState: initialQueryVariableModelState, reducer: queryVariableReducer, picker: OptionsPicker, diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 15cd0f6ff8f..f5122537cd4 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -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', - }) - ); - }); - }); }); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 2dfb3b26c13..21e3b2033cd 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -55,7 +55,7 @@ export const initDashboardTemplating = (list: VariableModel[]): ThunkResult [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); + }); + }); +}); diff --git a/public/app/features/variables/state/processVariable.test.ts b/public/app/features/variables/state/processVariable.test.ts index 481ad2eaf0c..1c9df4c7c63 100644 --- a/public/app/features/variables/state/processVariable.test.ts +++ b/public/app/features/variables/state/processVariable.test.ts @@ -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') diff --git a/public/app/features/variables/state/reducers.test.ts b/public/app/features/variables/state/reducers.test.ts index 593e3137912..37a185704f3 100644 --- a/public/app/features/variables/state/reducers.test.ts +++ b/public/app/features/variables/state/reducers.test.ts @@ -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 = { + 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 = { - 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('mockAction'); reducerTester() .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 = { - 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('mockAction'); reducerTester() .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 = { - 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('mockAction'); reducerTester() .givenReducer(variablesReducer, initialState) .whenActionIsDispatched(mockAction('mocked')) .thenStateShouldEqual(initialState); - expect(variableAdapter.reducer).toHaveBeenCalledTimes(0); + expect(variableAdapters.get('mock').reducer).toHaveBeenCalledTimes(0); }); }); }); diff --git a/public/app/features/variables/state/sharedReducer.test.ts b/public/app/features/variables/state/sharedReducer.test.ts index dc85a898331..f23b03bba78 100644 --- a/public/app/features/variables/state/sharedReducer.test.ts +++ b/public/app/features/variables/state/sharedReducer.test.ts @@ -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() .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() @@ -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() diff --git a/public/app/features/variables/state/variablesReducer.ts b/public/app/features/variables/state/variablesReducer.ts index a2a6eead163..10fcaf23cad 100644 --- a/public/app/features/variables/state/variablesReducer.ts +++ b/public/app/features/variables/state/variablesReducer.ts @@ -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 diff --git a/public/app/features/variables/textbox/actions.test.ts b/public/app/features/variables/textbox/actions.test.ts index e7465953195..a0995860cf5 100644 --- a/public/app/features/variables/textbox/actions.test.ts +++ b/public/app/features/variables/textbox/actions.test.ts @@ -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 () => { diff --git a/public/app/features/variables/textbox/adapter.ts b/public/app/features/variables/textbox/adapter.ts index 66d2f998b9c..c7ffc6ffcf3 100644 --- a/public/app/features/variables/textbox/adapter.ts +++ b/public/app/features/variables/textbox/adapter.ts @@ -12,8 +12,9 @@ import { toVariableIdentifier } from '../state/types'; export const createTextBoxVariableAdapter = (): VariableAdapter => { 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,