From d953511e0202838ff22dc2745acefcc99bd383d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 17 Mar 2020 14:10:30 +0100 Subject: [PATCH] Variables: adds onTimeRangeUpdated to newVariables (#22821) * Feature: adds onTimeRangeUpdated to newVariables * Refactor: removes VariableWithRefresh and unused func * Refactor: adds console output when something throws as well --- .../dashboard/state/DashboardModel.ts | 3 + public/app/features/templating/variable.ts | 2 +- .../features/variables/state/actions.test.ts | 175 +++++++++++++++++- .../app/features/variables/state/actions.ts | 43 +++++ .../app/features/variables/state/helpers.ts | 4 +- public/test/core/redux/reduxTester.ts | 2 + 6 files changed, 224 insertions(+), 5 deletions(-) diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 3bafdefbc43..40e25f84467 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -17,6 +17,8 @@ import { VariableModel } from '../../templating/variable'; import { getConfig } from '../../../core/config'; import { getVariableClones, getVariables } from 'app/features/variables/state/selectors'; import { variableAdapters } from 'app/features/variables/adapters'; +import { onTimeRangeUpdated } from 'app/features/variables/state/actions'; +import { dispatch } from '../../../store/store'; export interface CloneOptions { saveVariables?: boolean; @@ -276,6 +278,7 @@ export class DashboardModel { timeRangeUpdated(timeRange: TimeRange) { this.events.emit(CoreEvents.timeRangeUpdated, timeRange); + dispatch(onTimeRangeUpdated(timeRange)); } startRefresh() { diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 461eb24082a..8aa7efbe7cb 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -107,8 +107,8 @@ export interface IntervalVariableModel extends VariableWithOptions { export interface CustomVariableModel extends VariableWithMultiSupport {} export interface DataSourceVariableModel extends VariableWithMultiSupport { - refresh: VariableRefresh; regex: string; + refresh: VariableRefresh; } export interface QueryVariableModel extends DataSourceVariableModel { diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index feb4cb86879..b09a0ca4d65 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,3 +1,4 @@ +import { AnyAction } from 'redux'; import { UrlQueryMap } from '@grafana/runtime'; import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer, variableMockBuilder } from './helpers'; @@ -8,10 +9,23 @@ import { createTextBoxVariableAdapter } from '../textbox/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { TemplatingState } from 'app/features/variables/state/reducers'; -import { initDashboardTemplating, processVariables, setOptionFromUrl, validateVariableSelectionState } from './actions'; +import { + initDashboardTemplating, + onTimeRangeUpdated, + OnTimeRangeUpdatedDependencies, + processVariables, + setOptionFromUrl, + validateVariableSelectionState, +} from './actions'; import { addInitLock, addVariable, removeInitLock, resolveInitLock, setCurrentVariableValue } from './sharedReducer'; import { toVariableIdentifier, toVariablePayload } from './types'; -import { AnyAction } from 'redux'; +import { TemplateSrv } from '../../templating/template_srv'; +import { Emitter } from '../../../core/core'; +import { createIntervalVariableAdapter } from '../interval/adapter'; +import { VariableRefresh } from '../../templating/variable'; +import { DashboardModel } from '../../dashboard/state'; +import { DashboardState } from '../../../types'; +import { dateTime, TimeRange } from '@grafana/data'; describe('shared actions', () => { describe('when initDashboardTemplating is dispatched', () => { @@ -267,4 +281,161 @@ 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 = variableMockBuilder('interval') + .withUuid('0') + .withName('interval-0') + .withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d') + .withCurrent('1m') + .withRefresh(VariableRefresh.onTimeRangeChanged) + .create(); + + // the constant variable should be filtered out + const constant = variableMockBuilder('constant') + .withUuid('1') + .withName('constant-1') + .withOptions('a constant') + .withCurrent('a constant') + .create(); + const initialState = { + templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } }, + dashboard, + }; + + // updated variable state + const updatedVariable = variableMockBuilder('interval') + .withUuid('0') + .withName('interval-0') + .withOptions('1m') + .withCurrent('1m') + .withRefresh(VariableRefresh.onTimeRangeChanged) + .create(); + + const variable = args.update ? { ...updatedVariable } : { ...initialVariable }; + const state = { templating: { variables: { '0': variable, '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); + }); + }); + }); }); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index e4babb0ec0c..50062f21808 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -1,5 +1,7 @@ import castArray from 'lodash/castArray'; import { UrlQueryMap, UrlQueryValue } from '@grafana/runtime'; +import { AppEvents, TimeRange } from '@grafana/data'; +import angular from 'angular'; import { QueryVariableModel, @@ -15,6 +17,8 @@ import { Graph } from '../../../core/utils/dag'; import { updateLocation } from 'app/core/actions'; import { addInitLock, addVariable, removeInitLock, resolveInitLock, setCurrentVariableValue } from './sharedReducer'; import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types'; +import { appEvents } from '../../../core/core'; +import templateSrv from '../../templating/template_srv'; // process flow queryVariable // thunk => processVariables @@ -323,6 +327,45 @@ export const variableUpdated = (identifier: VariableIdentifier, emitChangeEvents }; }; +export interface OnTimeRangeUpdatedDependencies { + templateSrv: typeof templateSrv; + appEvents: typeof appEvents; +} + +export const onTimeRangeUpdated = ( + timeRange: TimeRange, + dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrv, appEvents: appEvents } +): ThunkResult => async (dispatch, getState) => { + dependencies.templateSrv.updateTimeRange(timeRange); + const variablesThatNeedRefresh = getVariables(getState()).filter(variable => { + if (variable.hasOwnProperty('refresh') && variable.hasOwnProperty('options')) { + const variableWithRefresh = (variable as unknown) as QueryVariableModel; + return variableWithRefresh.refresh === VariableRefresh.onTimeRangeChanged; + } + + return false; + }); + + const promises = variablesThatNeedRefresh.map(async (variable: VariableWithOptions) => { + const previousOptions = variable.options.slice(); + await variableAdapters.get(variable.type).updateOptions(variable); + const updatedVariable = getVariable(variable.uuid!, getState()); + if (angular.toJson(previousOptions) !== angular.toJson(updatedVariable.options)) { + const dashboard = getState().dashboard.getModel(); + dashboard?.templateVariableValueUpdated(); + } + }); + + try { + await Promise.all(promises); + const dashboard = getState().dashboard.getModel(); + dashboard?.startRefresh(); + } catch (error) { + console.error(error); + dependencies.appEvents.emit(AppEvents.alertError, ['Template variable service failed', error.message]); + } +}; + const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { const queryParams = getState().location.query; diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index 390121cd342..f63e3a982f0 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -86,8 +86,8 @@ export const variableMockBuilder = (type: VariableType) => { return instance; }; - const withCurrent = (text: string | string[]) => { - model.current = { text, value: text, selected: true }; + const withCurrent = (text: string | string[], value?: string | string[]) => { + model.current = { text, value: value ?? text, selected: true }; return instance; }; diff --git a/public/test/core/redux/reduxTester.ts b/public/test/core/redux/reduxTester.ts index 0a53c6eab9f..fccb2d0256c 100644 --- a/public/test/core/redux/reduxTester.ts +++ b/public/test/core/redux/reduxTester.ts @@ -55,6 +55,7 @@ export const reduxTester = (args?: ReduxTesterArguments): ReduxTes middleware: [logActionsMiddleWare, thunk], preloadedState, }); + setStore(store as any); return instance; @@ -67,6 +68,7 @@ export const reduxTester = (args?: ReduxTesterArguments): ReduxTes if (clearPreviousActions) { dispatchedActions.length = 0; } + store.dispatch(action); return instance;