Variables: Only update panels that are impacted by variable change (#39420)

* Refactor: adds affectedPanelIds and fixes some bugs

* Refactor: Fixes all dependencies and affected panel ids

* Refactor: glue it together with events

* Chore: remove debug code

* Chore: remove unused events

* Chore: removes unused function

* Chore: reverts processRepeats

* Chore: update to use redux state

* Refactor: adds feature toggle in variables settings

* Refactor: adds appEvents to jest-setup

* Tests: adds tests for strict panel refresh logic

* Refactor: small refactor

* Refactor: moved to more events

* Tests: fixes test

* Refactor: makes sure we store strictPanelRefreshMode in dashboard model

* Refactor: reporting and adds tests

* Tests: fix broken tests

* Tests: fix broken initDashboard test

* Tests: fix broken Wrapper test

* Refactor: adds solution for $__all_variables

* Chore: updates to radio button

* Refactor: removes toggle and calculates threshold instead

* Chore: fix up tests

* Refactor: moving functions around

* Tests: fixes broken test

* Update public/app/features/dashboard/services/TimeSrv.ts

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Chore: fix after PR comments

* Chore: fix import and add comment

* Chore: update after PR comments

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
This commit is contained in:
Hugo Häggmark 2021-11-09 12:30:04 +01:00 committed by GitHub
parent 077b1e847a
commit b4f00d6312
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 2278 additions and 127 deletions

View File

@ -1,9 +1,13 @@
import { includes, isDate } from 'lodash';
import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper';
import { DateTime, dateTime, dateTimeForTimeZone, DurationUnit, isDateTime, ISO_8601 } from './moment_wrapper';
import { TimeZone } from '../types/index';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's', 'Q'];
/**
* Determine if a string contains a relative date time.
* @param text
*/
export function isMathString(text: string | DateTime | Date): boolean {
if (!text) {
return false;

View File

@ -1,7 +1,8 @@
import { TimeSrv } from './TimeSrv';
import { ContextSrvStub } from 'test/specs/helpers';
import { isDateTime, dateTime } from '@grafana/data';
import { dateTime, isDateTime } from '@grafana/data';
import { HistoryWrapper, locationService, setLocationService } from '@grafana/runtime';
import { beforeEach } from '../../../../test/lib/common';
jest.mock('app/core/core', () => ({
appEvents: {
@ -261,4 +262,42 @@ describe('timeSrv', () => {
expect(_dashboard.refresh).toBe('10s');
});
});
describe('isRefreshOutsideThreshold', () => {
const originalNow = Date.now;
beforeEach(() => {
Date.now = jest.fn(() => 60000);
});
afterEach(() => {
Date.now = originalNow;
});
describe('when called and current time range is absolute', () => {
it('then it should return false', () => {
timeSrv.setTime({ from: dateTime(), to: dateTime() });
expect(timeSrv.isRefreshOutsideThreshold(0, 0.05)).toBe(false);
});
});
describe('when called and current time range is relative', () => {
describe('and last refresh is within threshold', () => {
it('then it should return false', () => {
timeSrv.setTime({ from: 'now-1m', to: 'now' });
expect(timeSrv.isRefreshOutsideThreshold(57001, 0.05)).toBe(false);
});
});
describe('and last refresh is outside the threshold', () => {
it('then it should return true', () => {
timeSrv.setTime({ from: 'now-1m', to: 'now' });
expect(timeSrv.isRefreshOutsideThreshold(57000, 0.05)).toBe(true);
});
});
});
});
});

View File

@ -353,6 +353,23 @@ export class TimeSrv {
to: toUtc(to),
});
}
// isRefreshOutsideThreshold function calculates the difference between last refresh and now
// if the difference is outside 5% of the current set time range then the function will return true
// if the difference is within 5% of the current set time range then the function will return false
// if the current time range is absolute (i.e. not using relative strings like now-5m) then the function will return false
isRefreshOutsideThreshold(lastRefresh: number, threshold = 0.05) {
const timeRange = this.timeRange();
if (dateMath.isMathString(timeRange.raw.from)) {
const totalRange = timeRange.to.diff(timeRange.from);
const msSinceLastRefresh = Date.now() - lastRefresh;
const msThreshold = totalRange * threshold;
return msSinceLastRefresh >= msThreshold;
}
return false;
}
}
let singleton: TimeSrv | undefined;

View File

@ -0,0 +1,121 @@
import { DashboardModel } from './DashboardModel';
import { appEvents } from '../../../core/core';
import { VariablesChanged } from '../../variables/types';
import { PanelModel } from './PanelModel';
import { getTimeSrv, setTimeSrv } from '../services/TimeSrv';
import { afterEach, beforeEach } from '../../../../test/lib/common';
function getTestContext({
usePanelInEdit,
usePanelInView,
}: { usePanelInEdit?: boolean; usePanelInView?: boolean } = {}) {
jest.clearAllMocks();
const dashboard = new DashboardModel({});
const startRefreshMock = jest.fn();
dashboard.startRefresh = startRefreshMock;
const panelInView = new PanelModel({ id: 99 });
const panelInEdit = new PanelModel({ id: 100 });
const panelIds = [1, 2, 3];
if (usePanelInEdit) {
dashboard.panelInEdit = panelInEdit;
panelIds.push(panelInEdit.id);
}
if (usePanelInView) {
dashboard.panelInView = panelInView;
panelIds.push(panelInView.id);
}
appEvents.publish(new VariablesChanged({ panelIds }));
return { dashboard, startRefreshMock, panelInEdit, panelInView };
}
describe('Strict panel refresh', () => {
describe('when there is no panel in full view or panel in panel edit during variable change', () => {
it('then all affected panels should be refreshed', () => {
const { startRefreshMock } = getTestContext();
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
});
});
describe('testing refresh threshold', () => {
const originalTimeSrv = getTimeSrv();
let isRefreshOutsideThreshold = false;
beforeEach(() => {
setTimeSrv({
isRefreshOutsideThreshold: () => isRefreshOutsideThreshold,
} as any);
});
afterEach(() => {
setTimeSrv(originalTimeSrv);
});
describe('when the dashboard has not been refreshed within the threshold', () => {
it(' then all panels should be refreshed', () => {
isRefreshOutsideThreshold = true;
const { startRefreshMock } = getTestContext();
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith(undefined);
});
});
describe('when the dashboard has been refreshed within the threshold', () => {
it('then all affected panels should be refreshed', () => {
isRefreshOutsideThreshold = false;
const { startRefreshMock } = getTestContext();
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
});
});
});
describe('when there is a panel in full view during variable change', () => {
it('then all affected panels should be refreshed', () => {
const { panelInView, startRefreshMock } = getTestContext({ usePanelInView: true });
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInView.id]);
});
describe('and when exitViewPanel is called', () => {
it('then all affected panels except the panel in full view should be refreshed', () => {
const { dashboard, panelInView, startRefreshMock } = getTestContext({ usePanelInView: true });
startRefreshMock.mockClear();
dashboard.exitViewPanel(panelInView);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
expect(dashboard['panelsAffectedByVariableChange']).toBeNull();
});
});
});
describe('when there is a panel in panel edit during variable change', () => {
it('then all affected panels should be refreshed', () => {
const { panelInEdit, startRefreshMock } = getTestContext({ usePanelInEdit: true });
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInEdit.id]);
});
describe('and when exitViewPanel is called', () => {
it('then all affected panels except the panel in panel edit should be refreshed', () => {
const { dashboard, startRefreshMock } = getTestContext({ usePanelInEdit: true });
startRefreshMock.mockClear();
dashboard.exitPanelEditor();
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
expect(dashboard['panelsAffectedByVariableChange']).toBeNull();
});
});
});
});

View File

@ -915,17 +915,6 @@ describe('exitViewPanel', () => {
expect(dashboard.startRefresh).not.toHaveBeenCalled();
});
describe('and there is a change that affects all panels', () => {
it('then startRefresh is not called', () => {
const { dashboard, panel } = getTestContext();
dashboard.setChangeAffectsAllPanels();
dashboard.exitViewPanel(panel);
expect(dashboard.startRefresh).toHaveBeenCalled();
});
});
});
});
@ -977,44 +966,9 @@ describe('exitPanelEditor', () => {
dashboard.exitPanelEditor();
expect(timeSrvMock.resumeAutoRefresh).toHaveBeenCalled();
});
describe('and there is a change that affects all panels', () => {
it('then startRefresh is called', () => {
const { dashboard } = getTestContext();
dashboard.setChangeAffectsAllPanels();
dashboard.exitPanelEditor();
expect(dashboard.startRefresh).toHaveBeenCalled();
});
});
});
});
describe('setChangeAffectsAllPanels', () => {
it.each`
panelInEdit | panelInView | expected
${null} | ${null} | ${false}
${undefined} | ${undefined} | ${false}
${null} | ${{}} | ${true}
${undefined} | ${{}} | ${true}
${{}} | ${null} | ${true}
${{}} | ${undefined} | ${true}
${{}} | ${{}} | ${true}
`(
'when called and panelInEdit:{$panelInEdit} and panelInView:{$panelInView}',
({ panelInEdit, panelInView, expected }) => {
const dashboard = new DashboardModel({});
dashboard.panelInEdit = panelInEdit;
dashboard.panelInView = panelInView;
dashboard.setChangeAffectsAllPanels();
expect(dashboard['hasChangesThatAffectsAllPanels']).toEqual(expected);
}
);
});
describe('initEditPanel', () => {
function getTestContext() {
const dashboard = new DashboardModel({});

View File

@ -47,6 +47,13 @@ import { getTimeSrv } from '../services/TimeSrv';
import { mergePanels, PanelMergeInfo } from '../utils/panelMerge';
import { isOnTheSameGridRow } from './utils';
import { RefreshEvent, TimeRangeUpdatedEvent } from '@grafana/runtime';
import { Subscription } from 'rxjs';
import { appEvents } from '../../../core/core';
import {
VariablesChanged,
VariablesChangedInUrl,
VariablesFinishedProcessingTimeRangeChange,
} from '../../variables/types';
export interface CloneOptions {
saveVariables?: boolean;
@ -100,7 +107,9 @@ export class DashboardModel {
panelInEdit?: PanelModel;
panelInView?: PanelModel;
fiscalYearStartMonth?: number;
private hasChangesThatAffectsAllPanels: boolean;
private panelsAffectedByVariableChange: number[] | null;
private appEventsSubscription: Subscription;
private lastRefresh: number;
// ------------------
// not persisted
@ -123,7 +132,9 @@ export class DashboardModel {
panelInView: true,
getVariablesFromState: true,
formatDate: true,
hasChangesThatAffectsAllPanels: true,
appEventsSubscription: true,
panelsAffectedByVariableChange: true,
lastRefresh: true,
};
constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) {
@ -167,7 +178,19 @@ export class DashboardModel {
this.addBuiltInAnnotationQuery();
this.sortPanelsByGridPos();
this.hasChangesThatAffectsAllPanels = false;
this.panelsAffectedByVariableChange = null;
this.appEventsSubscription = new Subscription();
this.lastRefresh = Date.now();
this.appEventsSubscription.add(appEvents.subscribe(VariablesChanged, this.variablesChangedHandler.bind(this)));
this.appEventsSubscription.add(
appEvents.subscribe(
VariablesFinishedProcessingTimeRangeChange,
this.variablesFinishedProcessingTimeRangeChangeHandler.bind(this)
)
);
this.appEventsSubscription.add(
appEvents.subscribe(VariablesChangedInUrl, this.variablesChangedInUrlHandler.bind(this))
);
}
addBuiltInAnnotationQuery() {
@ -355,17 +378,22 @@ export class DashboardModel {
dispatch(onTimeRangeUpdated(timeRange));
}
startRefresh() {
startRefresh(affectedPanelIds?: number[]) {
this.events.publish(new RefreshEvent());
this.lastRefresh = Date.now();
if (this.panelInEdit) {
this.panelInEdit.refresh();
return;
if (!affectedPanelIds || affectedPanelIds.includes(this.panelInEdit.id)) {
this.panelInEdit.refresh();
return;
}
}
for (const panel of this.panels) {
if (!this.otherPanelInFullscreen(panel)) {
panel.refresh();
if (!affectedPanelIds || affectedPanelIds.includes(panel.id)) {
panel.refresh();
}
}
}
}
@ -403,29 +431,23 @@ export class DashboardModel {
exitViewPanel(panel: PanelModel) {
this.panelInView = undefined;
panel.setIsViewing(false);
this.refreshIfChangeAffectsAllPanels();
this.refreshIfPanelsAffectedByVariableChange();
}
exitPanelEditor() {
this.panelInEdit!.destroy();
this.panelInEdit = undefined;
this.refreshIfChangeAffectsAllPanels();
getTimeSrv().resumeAutoRefresh();
this.refreshIfPanelsAffectedByVariableChange();
}
setChangeAffectsAllPanels() {
if (this.panelInEdit || this.panelInView) {
this.hasChangesThatAffectsAllPanels = true;
}
}
private refreshIfChangeAffectsAllPanels() {
if (!this.hasChangesThatAffectsAllPanels) {
private refreshIfPanelsAffectedByVariableChange() {
if (!this.panelsAffectedByVariableChange) {
return;
}
this.hasChangesThatAffectsAllPanels = false;
this.startRefresh();
this.startRefresh(this.panelsAffectedByVariableChange);
this.panelsAffectedByVariableChange = null;
}
private ensureListExist(data: any) {
@ -916,6 +938,7 @@ export class DashboardModel {
}
destroy() {
this.appEventsSubscription.unsubscribe();
this.events.removeAllListeners();
for (const panel of this.panels) {
panel.destroy();
@ -1195,4 +1218,40 @@ export class DashboardModel {
};
});
}
private variablesChangedHandler(event: VariablesChanged) {
this.variablesChangedBaseHandler(event, true);
}
private variablesFinishedProcessingTimeRangeChangeHandler(event: VariablesFinishedProcessingTimeRangeChange) {
this.variablesChangedBaseHandler(event);
}
private variablesChangedBaseHandler(
event: VariablesChanged | VariablesFinishedProcessingTimeRangeChange,
processRepeats = false
) {
if (processRepeats) {
this.processRepeats();
}
if (!event.payload.panelIds || getTimeSrv().isRefreshOutsideThreshold(this.lastRefresh)) {
// passing undefined in panelIds means we want to update all panels
this.startRefresh(undefined);
return;
}
if (this.panelInEdit || this.panelInView) {
this.panelsAffectedByVariableChange = event.payload.panelIds.filter(
(id) => id !== (this.panelInEdit?.id ?? this.panelInView?.id)
);
}
this.startRefresh(event.payload.panelIds);
}
private variablesChangedInUrlHandler(event: VariablesChangedInUrl) {
this.templateVariableValueUpdated();
this.startRefresh(event.payload.panelIds);
}
}

View File

@ -34,6 +34,10 @@ jest.mock('app/core/core', () => {
contextSrv: {
hasPermission: () => true,
},
appEvents: {
subscribe: () => {},
publish: () => {},
},
};
});

View File

@ -18,11 +18,11 @@ import {
decorateWithLogsResult,
decorateWithTableResult,
} from './decorators';
import { describe } from '../../../../test/lib/common';
import { ExplorePanelData } from 'app/types';
import TableModel from 'app/core/table_model';
jest.mock('@grafana/data/src/datetime/formatter', () => ({
jest.mock('@grafana/data', () => ({
...(jest.requireActual('@grafana/data') as any),
dateTimeFormat: () => 'format() jest mocked',
dateTimeFormatTimeAgo: (ts: any) => 'fromNow() jest mocked',
}));

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
import { variableAdapters } from '../adapters';
import { DashboardModel } from '../../dashboard/state';
import { DashboardModel, PanelModel } from '../../dashboard/state';
import { isAdHoc } from '../guard';
import { safeStringifyValue } from '../../../core/utils/explore';
import { VariableModel } from '../types';
import { containsVariable, variableRegex, variableRegexExec } from '../utils';
import { DataLinkBuiltInVars } from '@grafana/data';
export interface GraphNode {
id: string;
@ -108,7 +109,7 @@ export const getUnknownVariableStrings = (variables: VariableModel[], model: any
};
const validVariableNames: Record<string, RegExp[]> = {
alias: [/^m$/, /^measurement$/, /^col$/, /^tag_\w+|\d+$/],
alias: [/^m$/, /^measurement$/, /^col$/, /^tag_(\w+|\d+)$/],
query: [/^timeFilter$/],
};
@ -122,7 +123,12 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
const isValidName = validVariableNames[key]
? validVariableNames[key].find((regex: RegExp) => regex.test(variableId))
: undefined;
const hasVariable = containsVariable(value, variableId);
let hasVariable = containsVariable(value, variableId);
if (key === 'repeat' && value === variableId) {
// repeat stores value without variable format
hasVariable = true;
}
if (!isValidName && hasVariable) {
all = {
@ -132,13 +138,18 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
}
return all;
}, {});
}, {} as Record<string, any>);
const objectValues = Object.keys(parent.value).reduce((all, key) => {
const value = parent.value[key];
if (value && typeof value === 'object' && Object.keys(value).length) {
const id = value.title || value.name || value.id || key;
let id = value.title || value.name || value.id || key;
if (Array.isArray(parent.value) && parent.key === 'panels') {
id = `${id}[${value.id}]`;
}
const newResult = getPropsWithVariable(variableId, { key, value }, {});
if (Object.keys(newResult).length) {
all = {
...all,
@ -148,7 +159,7 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
}
return all;
}, {});
}, {} as Record<string, any>);
if (Object.keys(stringValues).length || Object.keys(objectValues).length) {
result = {
@ -206,6 +217,93 @@ export const createUsagesNetwork = (variables: VariableModel[], dashboard: Dashb
return { unUsed, unknown, usages };
};
/*
getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable
change. It will traverse all chained variables to identify all cascading changes too.
This is done entirely by parsing the current dashboard json and doesn't take under consideration a user cancelling
a variable query or any faulty variable queries.
This doesn't take circular dependencies in consideration.
*/
export function getAllAffectedPanelIdsForVariableChange(
variableId: string,
variables: VariableModel[],
panels: PanelModel[]
): number[] {
let affectedPanelIds: number[] = getAffectedPanelIdsForVariable(variableId, panels);
const affectedPanelIdsForAllVariables = getAffectedPanelIdsForVariable(DataLinkBuiltInVars.includeVars, panels);
affectedPanelIds = [...new Set([...affectedPanelIdsForAllVariables, ...affectedPanelIds])];
const dependencies = getDependenciesForVariable(variableId, variables, new Set());
for (const dependency of dependencies) {
const affectedPanelIdsForDependency = getAffectedPanelIdsForVariable(dependency, panels);
affectedPanelIds = [...new Set([...affectedPanelIdsForDependency, ...affectedPanelIds])];
}
return affectedPanelIds;
}
export function getDependenciesForVariable(
variableId: string,
variables: VariableModel[],
deps: Set<string>
): Set<string> {
if (!variables.length) {
return deps;
}
for (const variable of variables) {
if (variable.name === variableId) {
continue;
}
const depends = variableAdapters.get(variable.type).dependsOn(variable, { name: variableId });
if (!depends) {
continue;
}
deps.add(variable.name);
deps = getDependenciesForVariable(variable.name, variables, deps);
}
return deps;
}
export function getAffectedPanelIdsForVariable(variableId: string, panels: PanelModel[]): number[] {
if (!panels.length) {
return [];
}
const affectedPanelIds: number[] = [];
const repeatRegex = new RegExp(`"repeat":"${variableId}"`);
for (const panel of panels) {
const panelAsJson = safeStringifyValue(panel.getSaveModel());
// check for repeats that don't use variableRegex
const repeatMatches = panelAsJson.match(repeatRegex);
if (repeatMatches?.length) {
affectedPanelIds.push(panel.id);
continue;
}
const matches = panelAsJson.match(variableRegex);
if (!matches) {
continue;
}
for (const match of matches) {
const variableName = getVariableName(match);
if (variableName === variableId) {
affectedPanelIds.push(panel.id);
break;
}
}
}
return affectedPanelIds;
}
export interface UsagesToNetwork {
variable: VariableModel;
nodes: GraphNode[];

View File

@ -62,7 +62,7 @@ import { expect } from '../../../../test/lib/common';
import { ConstantVariableModel, VariableRefresh } from '../types';
import { updateVariableOptions } from '../query/reducer';
import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner';
import { setDataSourceSrv, setLocationService } from '@grafana/runtime';
import * as runtime from '@grafana/runtime';
import { LoadingState } from '@grafana/data';
import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers';
@ -86,7 +86,7 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({
}),
}));
setDataSourceSrv({
runtime.setDataSourceSrv({
get: getDatasource,
getList: getMetricSources,
} as any);
@ -151,7 +151,7 @@ describe('shared actions', () => {
templating: ({} as unknown) as TemplatingState,
};
const locationService: any = { getSearchObject: () => ({}) };
setLocationService(locationService);
runtime.setLocationService(locationService);
const variableQueryRunner: any = {
cancelRequest: jest.fn(),
queueRequest: jest.fn(),
@ -219,7 +219,7 @@ describe('shared actions', () => {
const list = [stats, substats];
const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT };
const locationService: any = { getSearchObject: () => query };
setLocationService(locationService);
runtime.setLocationService(locationService);
const preloadedState = {
templating: ({} as unknown) as TemplatingState,
};
@ -578,13 +578,19 @@ describe('shared actions', () => {
});
describe('initVariablesTransaction', () => {
const constant = constantBuilder().withId('constant').withName('constant').build();
const templating: any = { list: [constant] };
const uid = 'uid';
const dashboard: any = { title: 'Some dash', uid, templating };
function getTestContext() {
const reportSpy = jest.spyOn(runtime, 'reportInteraction').mockReturnValue(undefined);
const constant = constantBuilder().withId('constant').withName('constant').build();
const templating: any = { list: [constant] };
const uid = 'uid';
const dashboard: any = { title: 'Some dash', uid, templating };
return { reportSpy, constant, templating, uid, dashboard };
}
describe('when called and the previous dashboard has completed', () => {
it('then correct actions are dispatched', async () => {
const { constant, uid, dashboard } = getTestContext();
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard));
@ -611,6 +617,7 @@ describe('shared actions', () => {
describe('when called and the previous dashboard is still processing variables', () => {
it('then correct actions are dispatched', async () => {
const { constant, uid, dashboard } = getTestContext();
const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching };
const tester = await reduxTester<RootReducerType>({

View File

@ -12,6 +12,9 @@ import {
VariableModel,
VariableOption,
VariableRefresh,
VariablesChanged,
VariablesChangedInUrl,
VariablesFinishedProcessingTimeRangeChange,
VariableWithMultiSupport,
VariableWithOptions,
} from '../types';
@ -64,6 +67,8 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { cleanEditorState } from '../editor/reducer';
import { cleanPickerState } from '../pickers/OptionsPicker/reducer';
import { locationService } from '@grafana/runtime';
import { appEvents } from '../../../core/core';
import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils';
// process flow queryVariable
// thunk => processVariables
@ -492,13 +497,15 @@ const createGraph = (variables: VariableModel[]) => {
export const variableUpdated = (
identifier: VariableIdentifier,
emitChangeEvents: boolean
emitChangeEvents: boolean,
events: typeof appEvents = appEvents
): ThunkResult<Promise<void>> => {
return async (dispatch, getState) => {
const variableInState = getVariable(identifier.id, getState());
const state = getState();
const variableInState = getVariable(identifier.id, state);
// if we're initializing variables ignore cascading update because we are in a boot up scenario
if (getState().templating.transaction.status === TransactionStatus.Fetching) {
if (state.templating.transaction.status === TransactionStatus.Fetching) {
if (getVariableRefresh(variableInState) === VariableRefresh.never) {
// for variable types with updates that go the setValueFromUrl path in the update let's make sure their state is set to Done.
await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState)));
@ -507,8 +514,10 @@ export const variableUpdated = (
return Promise.resolve();
}
const variables = getVariables(getState());
const variables = getVariables(state);
const g = createGraph(variables);
const panels = state.dashboard?.getModel()?.panels ?? [];
const affectedPanelIds = getAllAffectedPanelIdsForVariableChange(variableInState.id, variables, panels);
const node = g.getNode(variableInState.name);
let promises: Array<Promise<any>> = [];
@ -525,11 +534,8 @@ export const variableUpdated = (
return Promise.all(promises).then(() => {
if (emitChangeEvents) {
const dashboard = getState().dashboard.getModel();
dashboard?.setChangeAffectsAllPanels();
dashboard?.processRepeats();
events.publish(new VariablesChanged({ panelIds: affectedPanelIds }));
locationService.partial(getQueryWithVariables(getState));
dashboard?.startRefresh();
}
});
};
@ -537,11 +543,12 @@ export const variableUpdated = (
export interface OnTimeRangeUpdatedDependencies {
templateSrv: TemplateSrv;
events: typeof appEvents;
}
export const onTimeRangeUpdated = (
timeRange: TimeRange,
dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv() }
dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), events: appEvents }
): ThunkResult<Promise<void>> => async (dispatch, getState) => {
dependencies.templateSrv.updateTimeRange(timeRange);
const variablesThatNeedRefresh = getVariables(getState()).filter((variable) => {
@ -559,9 +566,7 @@ export const onTimeRangeUpdated = (
try {
await Promise.all(promises);
const dashboard = getState().dashboard.getModel();
dashboard?.setChangeAffectsAllPanels();
dashboard?.startRefresh();
dependencies.events.publish(new VariablesFinishedProcessingTimeRangeChange({ panelIds: undefined }));
} catch (error) {
console.error(error);
dispatch(notifyApp(createVariableErrorNotification('Template variable service failed', error)));
@ -583,10 +588,10 @@ const timeRangeUpdated = (identifier: VariableIdentifier): ThunkResult<Promise<v
}
};
export const templateVarsChangedInUrl = (vars: ExtendedUrlQueryMap): ThunkResult<void> => async (
dispatch,
getState
) => {
export const templateVarsChangedInUrl = (
vars: ExtendedUrlQueryMap,
events: typeof appEvents = appEvents
): ThunkResult<void> => async (dispatch, getState) => {
const update: Array<Promise<any>> = [];
const dashboard = getState().dashboard.getModel();
for (const variable of getVariables(getState())) {
@ -617,8 +622,7 @@ export const templateVarsChangedInUrl = (vars: ExtendedUrlQueryMap): ThunkResult
if (update.length) {
await Promise.all(update);
dashboard?.templateVariableValueUpdated();
dashboard?.startRefresh();
events.publish(new VariablesChangedInUrl({ panelIds: undefined }));
}
};

View File

@ -23,10 +23,15 @@ import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsole
import { notifyApp } from '../../../core/reducers/appNotification';
import { expect } from '../../../../test/lib/common';
import { TemplatingState } from './reducers';
import { appEvents } from '../../../core/core';
variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]);
const dashboard = new DashboardModel({});
const getTestContext = () => {
jest.clearAllMocks();
const interval = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
@ -52,21 +57,19 @@ const getTestContext = () => {
};
const updateTimeRangeMock = jest.fn();
const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv;
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock };
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, events: appEvents };
const templateVariableValueUpdatedMock = jest.fn();
const setChangeAffectsAllPanelsMock = jest.fn();
const dashboard = ({
getModel: () =>
(({
templateVariableValueUpdated: templateVariableValueUpdatedMock,
startRefresh: startRefreshMock,
setChangeAffectsAllPanels: setChangeAffectsAllPanelsMock,
} as unknown) as DashboardModel),
} as unknown) as DashboardState;
const startRefreshMock = jest.fn();
const dashboardState = ({
getModel: () => {
dashboard.templateVariableValueUpdated = templateVariableValueUpdatedMock;
dashboard.startRefresh = startRefreshMock;
return dashboard;
},
} as unknown) as DashboardState;
const adapter = variableAdapters.get('interval');
const preloadedState = ({
dashboard,
dashboard: dashboardState,
templating: ({
variables: {
'interval-0': { ...interval },
@ -84,7 +87,6 @@ const getTestContext = () => {
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
setChangeAffectsAllPanelsMock,
};
};
@ -98,7 +100,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
setChangeAffectsAllPanelsMock,
} = getTestContext();
const tester = await reduxTester<RootReducerType>({ preloadedState })
@ -121,7 +122,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(1);
});
});
@ -135,7 +135,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
setChangeAffectsAllPanelsMock,
} = getTestContext();
const base = await reduxTester<RootReducerType>({ preloadedState })
@ -160,7 +159,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(1);
});
});
@ -175,7 +173,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
updateTimeRangeMock,
templateVariableValueUpdatedMock,
startRefreshMock,
setChangeAffectsAllPanelsMock,
} = getTestContext();
adapter.updateOptions = jest.fn().mockRejectedValue(new Error('Something broke'));
@ -204,7 +201,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(0);
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(0);
});
});
});

View File

@ -9,6 +9,8 @@ import { createCustomVariableAdapter } from '../custom/adapter';
import { VariablesState } from './types';
import { DashboardModel } from '../../dashboard/state';
const dashboardModel = new DashboardModel({});
variableAdapters.setInit(() => [createCustomVariableAdapter()]);
async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}) {
@ -20,14 +22,14 @@ async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}) {
const templateVariableValueUpdatedMock = jest.fn();
const startRefreshMock = jest.fn();
const dashboardModel: Partial<DashboardModel> = {
templateVariableValueUpdated: templateVariableValueUpdatedMock,
startRefresh: startRefreshMock,
templating: { list: [custom] },
};
const dashboard: DashboardState = {
...initialState,
getModel: () => (dashboardModel as unknown) as DashboardModel,
getModel: () => {
dashboardModel.templateVariableValueUpdated = templateVariableValueUpdatedMock;
dashboardModel.startRefresh = startRefreshMock;
dashboardModel.templating = { list: [custom] };
return dashboardModel;
},
};
const variables: VariablesState = { custom };

View File

@ -1,5 +1,6 @@
import { ComponentType } from 'react';
import {
BusEventWithPayload,
DataQuery,
DataSourceJsonData,
DataSourceRef,
@ -151,3 +152,19 @@ export type VariableQueryEditorType<
TQuery extends DataQuery = DataQuery,
TOptions extends DataSourceJsonData = DataSourceJsonData
> = ComponentType<VariableQueryProps> | ComponentType<QueryEditorProps<any, TQuery, TOptions, any>> | null;
export interface VariablesChangedEvent {
panelIds?: number[];
}
export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> {
static type = 'variables-changed';
}
export class VariablesFinishedProcessingTimeRangeChange extends BusEventWithPayload<VariablesChangedEvent> {
static type = 'variables-finished-processing-time-range-change';
}
export class VariablesChangedInUrl extends BusEventWithPayload<VariablesChangedEvent> {
static type = 'variables-changed-in-url';
}

View File

@ -23,6 +23,7 @@ jest.mock('app/core/core', () => ({
directive: () => {},
},
appEvents: {
subscribe: () => {},
on: () => {},
},
}));

View File

@ -1,8 +1,10 @@
import { configure } from 'enzyme';
import { EventBusSrv } from '@grafana/data';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import $ from 'jquery';
import 'mutationobserver-shim';
const testAppEvents = new EventBusSrv();
const global = window as any;
global.$ = global.jQuery = $;
@ -33,7 +35,7 @@ angular.module('grafana.directives', []);
angular.module('grafana.filters', []);
angular.module('grafana.routes', ['ngRoute']);
jest.mock('../app/core/core', () => ({}));
jest.mock('../app/core/core', () => ({ appEvents: testAppEvents }));
jest.mock('../app/angular/partials', () => ({}));
jest.mock('../app/features/plugins/plugin_loader', () => ({}));