From dbec2b02fd3b71c0ae90d440a16e6857b8f8bddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 18 Feb 2022 06:06:04 +0100 Subject: [PATCH] Variables: move state tree into a keyed state (#44642) * Variables: move state tree into a keyed state * Update public/app/features/variables/state/transactionReducer.ts Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Chore: fix prettier error * Chore: renamed slices and lastUid * Chore: rename toUidAction * Chore: rename dashboardVariableReducer * Chore: rename state prop back to templating * Chore renames variable.dashboardUid * Chore: rename toDashboardVariableIdentifier * Chore: rename getDashboardVariable * Chore: rename getDashboardVariablesState * Chore: rename getDashboardVariables * Chore: some more renames * Chore: small clean up * Chore: small rename * Chore: removes unused function * Chore: rename VariableModel.stateKey * Chore: rename KeyedVariableIdentifier.stateKey * user essentials mob! :trident: * user essentials mob! :trident: * user essentials mob! :trident: * user essentials mob! :trident: Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> Co-authored-by: kay delaney Co-authored-by: Alexandra Vargas Co-authored-by: Ashley Harrison --- .betterer.results | 2 +- public/app/core/reducers/root.ts | 4 +- .../DashboardSettings/DashboardSettings.tsx | 2 +- .../components/PanelEditor/PanelEditor.tsx | 6 +- .../RepeatRowSelect/RepeatRowSelect.tsx | 6 +- .../components/ShareModal/ShareLink.test.tsx | 2 +- .../dashboard/components/SubMenu/SubMenu.tsx | 8 +- .../dashboard/containers/DashboardPage.tsx | 2 +- .../dashboard/state/DashboardModel.ts | 18 +- .../app/features/dashboard/state/actions.ts | 2 +- .../dashboard/state/initDashboard.test.ts | 27 +- .../features/dashboard/state/initDashboard.ts | 7 +- .../panel/panellinks/specs/link_srv.test.ts | 2 +- .../AnnotationsWorker.test.ts | 2 +- .../DashboardQueryRunner.test.ts | 2 +- .../features/templating/template_srv.test.ts | 46 +- .../adhoc/AdHocVariableEditor.test.tsx | 13 +- .../variables/adhoc/AdHocVariableEditor.tsx | 32 +- .../features/variables/adhoc/actions.test.ts | 151 +++-- .../app/features/variables/adhoc/actions.ts | 142 +++-- .../app/features/variables/adhoc/adapter.ts | 5 +- .../variables/adhoc/picker/AdHocPicker.tsx | 7 +- .../features/variables/adhoc/reducer.test.ts | 3 +- .../app/features/variables/adhoc/reducer.ts | 3 +- .../variables/constant/actions.test.ts | 14 +- .../features/variables/constant/actions.ts | 9 +- .../features/variables/constant/adapter.ts | 10 +- .../variables/constant/reducer.test.ts | 6 +- .../features/variables/constant/reducer.ts | 4 +- .../features/variables/custom/actions.test.ts | 14 +- .../app/features/variables/custom/actions.ts | 9 +- .../app/features/variables/custom/adapter.ts | 11 +- .../features/variables/custom/reducer.test.ts | 6 +- .../app/features/variables/custom/reducer.ts | 3 +- .../datasource/DataSourceVariableEditor.tsx | 31 +- .../variables/datasource/actions.test.ts | 92 +-- .../features/variables/datasource/actions.ts | 18 +- .../features/variables/datasource/adapter.ts | 11 +- .../variables/datasource/reducer.test.ts | 5 +- .../features/variables/datasource/reducer.ts | 3 +- .../editor/SelectionOptionsEditor.tsx | 7 +- .../editor/VariableEditorContainer.tsx | 87 ++- .../variables/editor/VariableEditorEditor.tsx | 101 ++- .../variables/editor/VariableEditorList.tsx | 10 +- .../editor/VariableEditorListRow.tsx | 11 +- .../app/features/variables/editor/actions.ts | 86 +-- .../features/variables/editor/reducer.test.ts | 2 +- .../getAllVariableValuesForUrl.test.ts | 19 +- .../variables/getAllVariableValuesForUrl.ts | 1 - .../variables/interval/actions.test.ts | 75 ++- .../features/variables/interval/actions.ts | 13 +- .../features/variables/interval/adapter.ts | 14 +- .../variables/interval/reducer.test.ts | 3 +- .../features/variables/interval/reducer.ts | 3 +- .../OptionsPicker/OptionPicker.test.tsx | 19 +- .../pickers/OptionsPicker/OptionsPicker.tsx | 90 ++- .../pickers/OptionsPicker/actions.test.ts | 305 ++++++---- .../pickers/OptionsPicker/actions.ts | 86 +-- .../pickers/OptionsPicker/reducer.test.ts | 2 +- .../pickers/OptionsPicker/reducer.ts | 8 +- .../query/QueryVariableEditor.test.tsx | 11 +- .../variables/query/QueryVariableEditor.tsx | 30 +- .../query/VariableQueryRunner.test.ts | 42 +- .../variables/query/VariableQueryRunner.ts | 24 +- .../features/variables/query/actions.test.tsx | 306 ++++++---- .../app/features/variables/query/actions.ts | 85 ++- .../app/features/variables/query/adapter.ts | 11 +- .../variables/query/operators.test.ts | 18 +- .../app/features/variables/query/operators.ts | 12 +- .../features/variables/query/reducer.test.ts | 8 +- .../app/features/variables/query/reducer.ts | 3 +- .../query/variableQueryObserver.test.ts | 4 +- .../shared/testing/variableBuilder.ts | 5 + .../features/variables/state/actions.test.ts | 573 +++++++++++------- .../app/features/variables/state/actions.ts | 252 +++++--- .../app/features/variables/state/helpers.ts | 40 +- .../state/initVariableTransaction.test.ts | 179 +++--- .../state/keyedVariablesReducer.test.ts | 128 ++++ .../variables/state/keyedVariablesReducer.ts | 50 ++ ...igrateVariablesDatasourceNameToRef.test.ts | 56 +- .../state/onTimeRangeUpdated.test.ts | 87 +-- .../variables/state/processVariable.test.ts | 354 ++++++----- .../features/variables/state/reducers.test.ts | 8 +- .../app/features/variables/state/reducers.ts | 53 +- .../app/features/variables/state/selectors.ts | 98 ++- .../variables/state/setOptionFromUrl.test.ts | 109 +++- .../variables/state/sharedReducer.test.ts | 20 +- .../features/variables/state/sharedReducer.ts | 9 +- .../state/templateVarsChangedInUrl.test.ts | 10 +- .../variables/state/transactionReducer.ts | 16 +- public/app/features/variables/state/types.ts | 28 +- .../state/upgradeLegacyQueries.test.ts | 48 +- .../textbox/TextBoxVariablePicker.tsx | 15 +- .../variables/textbox/actions.test.ts | 47 +- .../app/features/variables/textbox/actions.ts | 28 +- .../variables/textbox/adapter.test.ts | 2 + .../app/features/variables/textbox/adapter.ts | 10 +- .../variables/textbox/reducer.test.ts | 3 +- .../app/features/variables/textbox/reducer.ts | 3 +- public/app/features/variables/types.ts | 2 + public/app/features/variables/utils.ts | 32 +- .../cloudwatch/specs/datasource.test.ts | 9 +- public/app/store/store.ts | 10 +- public/test/helpers/convertToStoreState.ts | 14 +- public/test/helpers/initTemplateSrv.ts | 4 +- 105 files changed, 2894 insertions(+), 1644 deletions(-) create mode 100644 public/app/features/variables/state/keyedVariablesReducer.test.ts create mode 100644 public/app/features/variables/state/keyedVariablesReducer.ts diff --git a/.betterer.results b/.betterer.results index 3a1f939d9d5..3c9d28c1017 100644 --- a/.betterer.results +++ b/.betterer.results @@ -209,7 +209,7 @@ exports[`no enzyme tests`] = { "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [ [1, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1141305288": [ + "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [ [1, 35, 13, "RegExp match", "2409514259"] ], "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [ diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 88ff7d0fdfb..de3b901986f 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -13,11 +13,11 @@ import usersReducers from 'app/features/users/state/reducers'; import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; -import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers'; import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers'; +import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; const rootReducers = { ...sharedReducers, @@ -33,10 +33,10 @@ const rootReducers = { ...userReducers, ...organizationReducers, ...ldapReducers, - ...templatingReducers, ...importDashboardReducers, ...panelEditorReducers, ...panelsReducers, + ...templatingReducers, plugins: pluginsReducer, }; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 125beaab782..486cacbe7c6 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -70,7 +70,7 @@ export function DashboardSettings({ dashboard, editview }: Props) { title: 'Variables', id: 'templating', icon: 'calculator-alt', - component: , + component: , }); pages.push({ diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 568c7ba1ede..1f50138843c 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -34,7 +34,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { toggleTableView } from './state/reducers'; import { getPanelEditorTabs } from './state/selectors'; -import { getVariables } from 'app/features/variables/state/selectors'; import { StoreState } from 'app/types'; import { DisplayMode, displayModes, PanelEditorTab } from './types'; @@ -55,6 +54,7 @@ import { notifyApp } from '../../../../core/actions'; import { PanelEditorTableView } from './PanelEditorTableView'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; import { getPanelStateForModel } from 'app/features/panel/state/selectors'; +import { getVariablesByKey } from '../../../variables/state/selectors'; interface OwnProps { dashboard: DashboardModel; @@ -62,7 +62,7 @@ interface OwnProps { tab?: string; } -const mapStateToProps = (state: StoreState) => { +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { const panel = state.panelEditor.getPanel(); const panelState = getPanelStateForModel(state, panel); @@ -73,7 +73,7 @@ const mapStateToProps = (state: StoreState) => { initDone: state.panelEditor.initDone, uiState: state.panelEditor.ui, tableViewEnabled: state.panelEditor.tableViewEnabled, - variables: getVariables(state), + variables: getVariablesByKey(ownProps.dashboard.uid, state), }; }; diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index 9dda02baca4..0ad229d26df 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { Select } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; -import { getVariables } from '../../../variables/state/selectors'; +import { getLastKey, getVariablesByKey } from '../../../variables/state/selectors'; import { StoreState } from '../../../../types'; export interface Props { @@ -13,7 +13,9 @@ export interface Props { } export const RepeatRowSelect: FC = ({ repeat, onChange, id }) => { - const variables = useSelector((state: StoreState) => getVariables(state)); + const variables = useSelector((state: StoreState) => { + return getVariablesByKey(getLastKey(state), state); + }); const variableOptions = useMemo(() => { const options = variables.map((item: any) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index 3ef55c9c1b1..b93827ad01a 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -96,7 +96,7 @@ function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContex } describe('ShareModal', () => { - let templateSrv = initTemplateSrv([]); + let templateSrv = initTemplateSrv('key', []); beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 01aeca07e61..14839796da0 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect, MapStateToProps } from 'react-redux'; import { StoreState } from '../../../../types'; -import { getSubMenuVariables } from '../../../variables/state/selectors'; +import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors'; import { VariableModel } from '../../../variables/types'; import { DashboardModel } from '../../state'; import { DashboardLinks } from './DashboardLinks'; @@ -64,9 +64,11 @@ class SubMenuUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state) => { +const mapStateToProps: MapStateToProps = (state, ownProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); return { - variables: getSubMenuVariables(state.templating.variables), + variables: getSubMenuVariables(uid, templatingState.variables), }; }; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 309adc30170..09ab1695faf 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -168,7 +168,7 @@ export class UnthemedDashboardPage extends PureComponent { const templateVarChanges = findTemplateVarChanges(this.props.queryParams, prevProps.queryParams); if (templateVarChanges) { - templateVarsChangedInUrl(templateVarChanges); + templateVarsChangedInUrl(dashboard.uid, templateVarChanges); } } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index bbda7085b5d..e3afab19aea 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -37,7 +37,7 @@ import { UrlQueryValue, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KioskMode } from 'app/types'; -import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; +import { GetVariables, getVariablesByKey } 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'; @@ -139,7 +139,7 @@ export class DashboardModel implements TimeModel { lastRefresh: true, }; - constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { + constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariablesByKey) { if (!data) { data = {}; } @@ -346,7 +346,7 @@ export class DashboardModel implements TimeModel { defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions ) { const originalVariables = this.originalTemplating; - const currentVariables = this.getVariablesFromState(); + const currentVariables = this.getVariablesFromState(this.uid); copy.templating = { list: currentVariables.map((variable) => @@ -374,7 +374,7 @@ export class DashboardModel implements TimeModel { timeRangeUpdated(timeRange: TimeRange) { this.events.publish(new TimeRangeUpdatedEvent(timeRange)); - dispatch(onTimeRangeUpdated(timeRange)); + dispatch(onTimeRangeUpdated(this.uid, timeRange)); } startRefresh(event: VariablesChangedEvent = { refreshAll: true, panelIds: [] }) { @@ -1085,11 +1085,11 @@ export class DashboardModel implements TimeModel { return; } - this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState()); + this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState(this.uid)); } hasVariableValuesChanged() { - return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState()); + return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState(this.uid)); } autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { @@ -1164,7 +1164,7 @@ export class DashboardModel implements TimeModel { } getVariables = () => { - return this.getVariablesFromState(); + return this.getVariablesFromState(this.uid); }; canAddAnnotations() { @@ -1179,7 +1179,7 @@ export class DashboardModel implements TimeModel { } private getPanelRepeatVariable(panel: PanelModel) { - return this.getVariablesFromState().find((variable) => variable.name === panel.repeat); + return this.getVariablesFromState(this.uid).find((variable) => variable.name === panel.repeat); } private isSnapshotTruthy() { @@ -1187,7 +1187,7 @@ export class DashboardModel implements TimeModel { } private hasVariables() { - return this.getVariablesFromState().length > 0; + return this.getVariablesFromState(this.uid).length > 0; } private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean { diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 1a503a276ad..9ae183fc475 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -119,12 +119,12 @@ export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch, if (dashboard) { dashboard.destroy(); + dispatch(cancelVariables(dashboard.uid)); } getTimeSrv().stopAutoRefresh(); dispatch(cleanUpDashboard()); - dispatch(cancelVariables()); }; export const updateTimeZoneDashboard = diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index f7083989a6b..0bcb74ef8d2 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -11,7 +11,7 @@ import { Echo } from '../../../core/services/echo/Echo'; import { variableAdapters } from 'app/features/variables/adapters'; import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter'; import { constantBuilder } from 'app/features/variables/shared/testing/builders'; -import { variablesInitTransaction } from '../../variables/state/transactionReducer'; +import { initialTransactionState, variablesInitTransaction } from '../../variables/state/transactionReducer'; import { keybindingSrv } from 'app/core/services/keybindingSrv'; import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; @@ -22,6 +22,7 @@ import { } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { emptyResult } from '../../query/state/DashboardQueryRunner/utils'; import { TransactionStatus } from '../../variables/types'; +import { getPreloadedState } from '../../variables/state/helpers'; jest.mock('app/core/services/backend_srv'); jest.mock('app/features/dashboard/services/TimeSrv', () => { @@ -53,7 +54,7 @@ interface ScenarioContext { } type ScenarioFn = (ctx: ScenarioContext) => void; - +const DASH_UID = 'DGmvKKxZz'; function describeInitScenario(description: string, scenarioFn: ScenarioFn) { describe(description, () => { const loaderSrv = { @@ -83,6 +84,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { templating: { list: [constantBuilder().build()], }, + uid: DASH_UID, }, })), }; @@ -100,7 +102,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { const ctx: ScenarioContext = { args: { - urlUid: 'DGmvKKxZz', + urlUid: DASH_UID, fixUrl: false, routeName: DashboardRoutes.Normal, }, @@ -120,10 +122,10 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { queries: [], }, }, - templating: { + ...getPreloadedState(DASH_UID, { variables: {}, - transaction: { uid: 'DGmvKKxZz', status: TransactionStatus.Completed }, - }, + transaction: { ...initialTransactionState, uid: DASH_UID, status: TransactionStatus.Completed }, + }), }, setup: (fn: () => void) => { setupFn = fn; @@ -258,7 +260,7 @@ describeInitScenario('Initializing existing dashboard', (ctx) => { }); it('Should initialize redux variables if newVariables is enabled', () => { - expect(ctx.actions[2].type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); }); }); @@ -287,3 +289,14 @@ describeInitScenario('Initializing previously canceled dashboard initialization' expect(getDashboardQueryRunner().run).toBeCalled(); }); }); + +describeInitScenario('Initializing snapshot dashboard', (ctx) => { + ctx.setup(() => { + ctx.args.urlUid = undefined; + }); + + it('Should send action initVariablesTransaction with correct payload', () => { + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.payload.uid).toBe(DASH_UID); + }); +}); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 876e2170e3a..91dc7c58280 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -23,6 +23,8 @@ import { emitDashboardViewEvent } from './analyticsProcessor'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { config, locationService } from '@grafana/runtime'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { getIfExistsLastKey } from '../../variables/state/selectors'; +import { toStateKey } from 'app/features/variables/utils'; export interface InitDashboardArgs { urlUid?: string; @@ -155,15 +157,16 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { timeSrv.init(dashboard); + const dashboardUid = toStateKey(args.urlUid ?? dashboard.uid); // template values service needs to initialize completely before the rest of the dashboard can load - await dispatch(initVariablesTransaction(args.urlUid!, dashboard)); + await dispatch(initVariablesTransaction(dashboardUid, dashboard)); // DashboardQueryRunner needs to run after all variables have been resolved so that any annotation query including a variable // will be correctly resolved const runner = createDashboardQueryRunner({ dashboard, timeSrv }); runner.run({ dashboard, range: timeSrv.timeRange() }); - if (getState().templating.transaction.uid !== args.urlUid) { + if (getIfExistsLastKey(getState()) !== dashboardUid) { // if a previous dashboard has slow running variable queries the batch uid will be the new one // but the args.urlUid will be the same as before initVariablesTransaction was called so then we can't continue initializing // the previous dashboard. diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index b32b5b62b15..b2b30096045 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -32,7 +32,7 @@ describe('linkSrv', () => { _dashboard.refresh = false; setTimeSrv(timeSrv); - templateSrv = initTemplateSrv([ + templateSrv = initTemplateSrv('key', [ { type: 'query', name: 'home', current: { value: '127.0.0.1' } }, { type: 'query', name: 'server1', current: { value: '192.168.0.100' } }, ]); diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts index daf7bc5dba1..8f6917004c9 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts @@ -67,7 +67,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index e68011186a4..ba65d463d4f 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -69,7 +69,7 @@ function expectOnResults(args: { expectCallback(value); done(); } catch (err) { - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index ebe981efc8f..c99d43b1a9f 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -9,6 +9,8 @@ import { FormatRegistryID } from './formatRegistry'; import { setDataSourceSrv } from '@grafana/runtime'; import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +const key = 'key'; + variableAdapters.setInit(() => [ createQueryVariableAdapter() as unknown as VariableAdapter, createAdHocVariableAdapter() as unknown as VariableAdapter, @@ -20,7 +22,7 @@ describe('templateSrv', () => { describe('init', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should initialize template data', () => { @@ -31,7 +33,7 @@ describe('templateSrv', () => { describe('replace can pass scoped vars', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('scoped vars should support objects', () => { @@ -115,7 +117,7 @@ describe('templateSrv', () => { describe('getAdhocFilters', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'datasource', name: 'ds', @@ -152,7 +154,7 @@ describe('templateSrv', () => { describe('replace can pass multi / all format', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -168,7 +170,7 @@ describe('templateSrv', () => { describe('when the globbed variable only has one value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -216,7 +218,7 @@ describe('templateSrv', () => { describe('variable with all option', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -254,7 +256,7 @@ describe('templateSrv', () => { describe('variable with all option and custom value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -298,19 +300,19 @@ describe('templateSrv', () => { describe('lucene format', () => { it('should properly escape $test with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:$test', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test}', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test:lucene} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test:lucene}', {}); expect(target).toBe('this:value\\/4'); }); @@ -318,7 +320,7 @@ describe('templateSrv', () => { describe('html format', () => { it('should encode values html escape sequences', () => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', current: { value: '' } }, ]); const target = _templateSrv.replace('$test', {}, 'html'); @@ -426,7 +428,7 @@ describe('templateSrv', () => { describe('can check if variable exists', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should return true if $test exists', () => { @@ -467,7 +469,7 @@ describe('templateSrv', () => { describe('can highlight variables in string', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should insert html', () => { @@ -488,7 +490,7 @@ describe('templateSrv', () => { describe('updateIndex with simple value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); }); it('should set current value and update template data', () => { @@ -499,7 +501,7 @@ describe('templateSrv', () => { describe('replaceWithText', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -544,7 +546,7 @@ describe('templateSrv', () => { describe('replaceWithText can pass all / multi value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -595,7 +597,7 @@ describe('templateSrv', () => { describe('built in interval variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([]); + _templateSrv = initTemplateSrv(key, []); }); it('should replace $__interval_ms with interval milliseconds', () => { @@ -608,7 +610,7 @@ describe('templateSrv', () => { describe('date formating', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([], { + _templateSrv = initTemplateSrv(key, [], { from: dateTime(1594671549254), to: dateTime(1595237229747), } as TimeRange); @@ -642,7 +644,7 @@ describe('templateSrv', () => { describe('handle objects gracefully', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); }); it('should not pass object to custom function', () => { @@ -658,7 +660,7 @@ describe('templateSrv', () => { describe('handle objects gracefully and call toString if defined', () => { beforeEach(() => { const value = { test: 'A', toString: () => 'hello' }; - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value } }]); }); it('should not pass object to custom function', () => { @@ -673,7 +675,7 @@ describe('templateSrv', () => { describe('adhoc variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'adhoc', name: 'adhoc', @@ -713,7 +715,7 @@ describe('templateSrv', () => { describe('queryparam', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'single', diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx index 82944657f9e..2bb3a2d7289 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; - -import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; -import { initialAdHocVariableModelState } from './reducer'; import { selectOptionInTest } from '@grafana/ui'; import { getSelectParent } from '@grafana/ui/src/components/Select/test-utils'; +import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; +import { adHocBuilder } from '../shared/testing/builders'; + const props = { extended: { dataSources: [ @@ -13,7 +13,7 @@ const props = { { text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } }, ], }, - variable: { ...initialAdHocVariableModelState }, + variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(), onPropChange: jest.fn(), // connected actions @@ -37,7 +37,10 @@ describe('AdHocVariableEditor', () => { render(); await selectOptionInTest(screen.getByLabelText('Data source'), 'Loki'); - expect(props.changeVariableDatasource).toBeCalledWith({ type: 'loki-ds', uid: 'abc' }); + expect(props.changeVariableDatasource).toBeCalledWith( + { type: 'adhoc', id: 'adhoc', rootStateKey: 'key' }, + { type: 'loki-ds', uid: 'abc' } + ); }); it('renders informational text', () => { diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx index f00a074c21d..3e4ca258479 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx @@ -5,15 +5,31 @@ import { DataSourceRef, SelectableValue } from '@grafana/data'; import { AdHocVariableModel } from '../types'; import { VariableEditorProps } from '../editor/types'; +import { initialVariableEditorState } from '../editor/reducer'; import { changeVariableDatasource, initAdHocVariableEditor } from './actions'; import { StoreState } from 'app/types'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getAdhocVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return { + extended: getAdhocVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getAdhocVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initAdHocVariableEditor, @@ -28,11 +44,17 @@ type Props = OwnProps & ConnectedProps; export class AdHocVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initAdHocVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initAdHocVariableEditor(rootStateKey); } onDatasourceChanged = (option: SelectableValue) => { - this.props.changeVariableDatasource(option.value); + this.props.changeVariableDatasource(toKeyedVariableIdentifier(this.props.variable), option.value); }; render() { diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index 1a7d69c63c7..becf0bb4a76 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -3,8 +3,7 @@ import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createAdHocVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { addFilter, AdHocTableOptions, @@ -21,6 +20,8 @@ import { VariableModel } from 'app/features/variables/types'; import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer'; import { adHocBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const getList = jest.fn().mockReturnValue([]); const getDatasource = jest.fn().mockResolvedValue({}); @@ -55,6 +56,7 @@ const expectedDatasources = [ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and filter already exist', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -71,12 +73,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([existingFilter]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -84,7 +87,9 @@ describe('adhoc actions', () => { const expectedQuery = { 'var-Filters': ['filter-key|!=|filter-existing', 'filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -92,6 +97,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no variable or filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -99,18 +105,23 @@ describe('adhoc actions', () => { operator: '=', }; - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -119,6 +130,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -128,12 +140,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -141,13 +154,16 @@ describe('adhoc actions', () => { const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when applyFilterFromTable is dispatched and adhoc variable with other datasource exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -157,13 +173,19 @@ describe('adhoc actions', () => { const existing = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withDatasource({ uid: 'elasticsearch' }) .build(); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(existing)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -173,7 +195,7 @@ describe('adhoc actions', () => { tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable, 1), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -182,6 +204,7 @@ describe('adhoc actions', () => { describe('when changeFilter is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -196,6 +219,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -206,12 +230,14 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(changeFilter('elastic-filter', update), true); + .whenAsyncActionIsDispatched(changeFilter(toKeyedVariableIdentifier(variable), update), true); const expectedQuery = { 'var-elastic-filter': ['key|!=|value'] }; const expectedUpdate = { index: 0, filter: updated }; - tester.thenDispatchedActionsShouldEqual(filterUpdated(toVariablePayload(variable, expectedUpdate))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterUpdated(toVariablePayload(variable, expectedUpdate))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -219,6 +245,7 @@ describe('adhoc actions', () => { describe('when addFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -233,6 +260,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -241,18 +269,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|!=|value'] }; const expectedFilter = { key: 'key', value: 'value', operator: '!=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when addFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const adding = { key: 'key', value: 'value', @@ -262,6 +293,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -270,19 +302,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, adding))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterAdded(toVariablePayload(variable, adding)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -291,17 +325,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const filter = { key: 'key', value: 'value', @@ -311,6 +346,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([filter]) .withDatasource({ uid: 'elasticsearch' }) @@ -319,17 +355,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when setFiltersFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -339,6 +376,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -352,7 +390,7 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(setFiltersFromUrl('elastic-filter', fromUrl), true); + .whenAsyncActionIsDispatched(setFiltersFromUrl(toKeyedVariableIdentifier(variable), fromUrl), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|=|value'] }; const expectedFilters = [ @@ -360,28 +398,40 @@ describe('adhoc actions', () => { { key: 'key', value: 'value', operator: '=', condition: '', name: 'value-2' }, ]; - tester.thenDispatchedActionsShouldEqual(filtersRestored(toVariablePayload(variable, expectedFilters))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filtersRestored(toVariablePayload(variable, expectedFilters))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when initAdHocVariableEditor is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; + getList.mockRestore(); getList.mockReturnValue(datasources); const tester = reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initAdHocVariableEditor()); + .whenActionIsDispatched(initAdHocVariableEditor(key)); - tester.thenDispatchedActionsShouldEqual(changeVariableEditorExtended({ dataSources: expectedDatasources })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, changeVariableEditorExtended({ dataSources: expectedDatasources })) + ); }); }); describe('when changeVariableDatasource is dispatched with unsupported datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'mysql' }; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue(null); @@ -391,25 +441,37 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ - infoText: 'This data source does not support ad hoc filters yet.', - dataSources: expectedDatasources, - }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction( + key, + changeVariableEditorExtended({ + infoText: 'This data source does not support ad hoc filters yet.', + dataSources: expectedDatasources, + }) + ) ); }); }); describe('when changeVariableDatasource is dispatched with datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'elasticsearch' }; const loadingText = 'Ad hoc filters are applied automatically to all queries that target this data source'; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue({ @@ -421,23 +483,26 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction(key, changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources })) ); }); }); }); function createAddVariableAction(variable: VariableModel, index = 0) { - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const global = false; const data = { global, index, model: { ...variable, index: -1, global } }; - return addVariable(toVariablePayload(identifier, data)); + return toKeyedAction(variable.rootStateKey!, addVariable(toVariablePayload(identifier, data))); } function createDatasource(name: string, selectable = true, isDefault = false): DataSourceInstanceSettings { diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index e004c66f86e..b49e398e524 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -3,8 +3,8 @@ import { StoreState, ThunkResult } from 'app/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { changeVariableEditorExtended } from '../editor/reducer'; import { addVariable, changeVariableProp } from '../state/sharedReducer'; -import { getNewVariableIndex, getVariable } from '../state/selectors'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { getLastKey, getNewVariableIndex, getVariable, getVariablesState } from '../state/selectors'; +import { AddVariable, KeyedVariableIdentifier } from '../state/types'; import { AdHocVariabelFilterUpdate, filterAdded, @@ -18,6 +18,8 @@ import { variableUpdated } from '../state/actions'; import { isAdHoc } from '../guard'; import { DataSourceRef, getDataSourceRef } from '@grafana/data'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; export interface AdHocTableOptions { datasource: DataSourceRef; @@ -35,6 +37,9 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult f.key === options.key && f.value === options.value); @@ -42,52 +47,66 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult => { +export const changeFilter = ( + identifier: KeyedVariableIdentifier, + update: AdHocVariabelFilterUpdate +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterUpdated(toVariablePayload(variable, update))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterUpdated(toVariablePayload(variable, update)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const removeFilter = (id: string, index: number): ThunkResult => { +export const removeFilter = (identifier: KeyedVariableIdentifier, index: number): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterRemoved(toVariablePayload(variable, index))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterRemoved(toVariablePayload(variable, index)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const addFilter = (id: string, filter: AdHocVariableFilter): ThunkResult => { +export const addFilter = (identifier: KeyedVariableIdentifier, filter: AdHocVariableFilter): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterAdded(toVariablePayload(variable, filter))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterAdded(toVariablePayload(variable, filter)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const setFiltersFromUrl = (id: string, filters: AdHocVariableFilter[]): ThunkResult => { +export const setFiltersFromUrl = ( + identifier: KeyedVariableIdentifier, + filters: AdHocVariableFilter[] +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filtersRestored(toVariablePayload(variable, filters))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filtersRestored(toVariablePayload(variable, filters)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResult => { +export const changeVariableDatasource = ( + identifier: KeyedVariableIdentifier, + datasource?: DataSourceRef +): ThunkResult => { return async (dispatch, getState) => { - const { editor } = getState().templating; + const { editor } = getVariablesState(identifier.rootStateKey, getState()); const extended = getAdhocVariableEditorState(editor); - const variable = getVariable(editor.id, getState()); - dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource }))); + const variable = getVariable(identifier, getState()); + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ) + ); const ds = await getDatasourceSrv().get(datasource); @@ -97,57 +116,70 @@ export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResul : 'This data source does not support ad hoc filters yet.'; dispatch( - changeVariableEditorExtended({ - infoText: message, - dataSources: extended?.dataSources ?? [], - }) + toKeyedAction( + identifier.rootStateKey, + changeVariableEditorExtended({ + infoText: message, + dataSources: extended?.dataSources ?? [], + }) + ) ); }; }; -export const initAdHocVariableEditor = (): ThunkResult => (dispatch) => { - const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); - const selectable = dataSources.reduce( - (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { - if (ds.meta.mixed) { +export const initAdHocVariableEditor = + (key: string): ThunkResult => + (dispatch) => { + const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); + const selectable = dataSources.reduce( + (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { + if (ds.meta.mixed) { + return all; + } + + const text = ds.isDefault ? `${ds.name} (default)` : ds.name; + const value = getDataSourceRef(ds); + all.push({ text, value }); + return all; - } + }, + [{ text: '', value: {} }] + ); - const text = ds.isDefault ? `${ds.name} (default)` : ds.name; - const value = getDataSourceRef(ds); - all.push({ text, value }); - - return all; - }, - [{ text: '', value: {} }] - ); - - dispatch( - changeVariableEditorExtended({ - dataSources: selectable, - }) - ); -}; + dispatch( + toKeyedAction( + key, + changeVariableEditorExtended({ + dataSources: selectable, + }) + ) + ); + }; const createAdHocVariable = (options: AdHocTableOptions): ThunkResult => { return (dispatch, getState) => { - const model = { + const key = getLastKey(getState()); + + const model: AdHocVariableModel = { ...cloneDeep(initialAdHocVariableModelState), datasource: options.datasource, name: filterTableName, id: filterTableName, + rootStateKey: key, }; const global = false; - const index = getNewVariableIndex(getState()); - const identifier: VariableIdentifier = { type: 'adhoc', id: model.id }; + const index = getNewVariableIndex(key, getState()); + const identifier: KeyedVariableIdentifier = { type: 'adhoc', id: model.id, rootStateKey: key }; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); }; }; -const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel => { - return Object.values(state.templating.variables).find( +const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => { + const key = getLastKey(state); + const templatingState = getVariablesState(key, state); + return Object.values(templatingState.variables).find( (v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid ) as AdHocVariableModel; }; diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts index 10adfce70b7..6f2ed40e074 100644 --- a/public/app/features/variables/adhoc/adapter.ts +++ b/public/app/features/variables/adhoc/adapter.ts @@ -8,6 +8,7 @@ import { adHocVariableReducer, initialAdHocVariableModelState } from './reducer' import { AdHocVariableEditor } from './AdHocVariableEditor'; import { setFiltersFromUrl } from './actions'; import * as urlParser from './urlParser'; +import { toKeyedVariableIdentifier } from '../utils'; const noop = async () => {}; @@ -24,11 +25,11 @@ export const createAdHocVariableAdapter = (): VariableAdapter { const filters = urlParser.toFilters(urlValue); - await dispatch(setFiltersFromUrl(variable.id, filters)); + await dispatch(setFiltersFromUrl(toKeyedVariableIdentifier(variable), filters)); }, updateOptions: noop, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx index 8abb7a6fbf3..1fa6e55e1ce 100644 --- a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx +++ b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx @@ -4,6 +4,7 @@ import { AdHocVariableFilter, AdHocVariableModel } from 'app/features/variables/ import { VariablePickerProps } from '../../pickers/types'; import { addFilter, changeFilter, removeFilter } from '../actions'; import { AdHocFilter } from './AdHocFilter'; +import { toKeyedVariableIdentifier } from '../../utils'; const mapDispatchToProps = { addFilter, @@ -23,15 +24,15 @@ type Props = OwnProps & ConnectedProps; */ export class AdHocPickerUnconnected extends PureComponent { addFilter = (filter: AdHocVariableFilter) => { - this.props.addFilter(this.props.variable.id, filter); + this.props.addFilter(toKeyedVariableIdentifier(this.props.variable), filter); }; removeFilter = (index: number) => { - this.props.removeFilter(this.props.variable.id, index); + this.props.removeFilter(toKeyedVariableIdentifier(this.props.variable), index); }; changeFilter = (index: number, filter: AdHocVariableFilter) => { - this.props.changeFilter(this.props.variable.id, { + this.props.changeFilter(toKeyedVariableIdentifier(this.props.variable), { index, filter, }); diff --git a/public/app/features/variables/adhoc/reducer.test.ts b/public/app/features/variables/adhoc/reducer.test.ts index b3645e38b1a..ce4b7948cd4 100644 --- a/public/app/features/variables/adhoc/reducer.test.ts +++ b/public/app/features/variables/adhoc/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { adHocVariableReducer, filterAdded, filterRemoved, filtersRestored, filterUpdated } from './reducer'; import { AdHocVariableFilter, AdHocVariableModel } from '../types'; import { createAdHocVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('adHocVariableReducer', () => { const adapter = createAdHocVariableAdapter(); diff --git a/public/app/features/variables/adhoc/reducer.ts b/public/app/features/variables/adhoc/reducer.ts index 3f5aa9f9222..f5ff0945961 100644 --- a/public/app/features/variables/adhoc/reducer.ts +++ b/public/app/features/variables/adhoc/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AdHocVariableFilter, AdHocVariableModel, initialVariableModelState } from 'app/features/variables/types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export interface AdHocVariabelFilterUpdate { index: number; diff --git a/public/app/features/variables/constant/actions.test.ts b/public/app/features/variables/constant/actions.test.ts index b483c8ad547..a609cdc69ce 100644 --- a/public/app/features/variables/constant/actions.test.ts +++ b/public/app/features/variables/constant/actions.test.ts @@ -4,9 +4,10 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { updateConstantVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createConstantOptionsFromQuery } from './reducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('constant actions', () => { variableAdapters.setInit(() => [createConstantVariableAdapter()]); @@ -22,6 +23,7 @@ describe('constant actions', () => { const variable: ConstantVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'constant', name: 'Constant', @@ -36,12 +38,14 @@ describe('constant actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateConstantVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateConstantVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createConstantOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createConstantOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/constant/actions.ts b/public/app/features/variables/constant/actions.ts index db27b06739a..6f865bcaa19 100644 --- a/public/app/features/variables/constant/actions.ts +++ b/public/app/features/variables/constant/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createConstantOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateConstantVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateConstantVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createConstantOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createConstantOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index 12aa0e85073..4555a63c788 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -6,8 +6,8 @@ import { VariableAdapter } from '../adapters'; import { constantVariableReducer, initialConstantVariableModelState } from './reducer'; import { ConstantVariableEditor } from './ConstantVariableEditor'; import { updateConstantVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createConstantVariableAdapter = (): VariableAdapter => { return { @@ -22,16 +22,16 @@ export const createConstantVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateConstantVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, current, options, ...rest } = cloneDeep(variable); + const { index, id, state, global, current, options, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/constant/reducer.test.ts b/public/app/features/variables/constant/reducer.test.ts index b6c937c52ea..932593582b2 100644 --- a/public/app/features/variables/constant/reducer.test.ts +++ b/public/app/features/variables/constant/reducer.test.ts @@ -1,10 +1,12 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { constantVariableReducer, createConstantOptionsFromQuery } from './reducer'; import { ConstantVariableModel } from '../types'; import { createConstantVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('constantVariableReducer', () => { const adapter = createConstantVariableAdapter(); diff --git a/public/app/features/variables/constant/reducer.ts b/public/app/features/variables/constant/reducer.ts index bf2f2d38be7..8a830d779d1 100644 --- a/public/app/features/variables/constant/reducer.ts +++ b/public/app/features/variables/constant/reducer.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { ConstantVariableModel, initialVariableModelState, VariableHide, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialConstantVariableModelState: ConstantVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/custom/actions.test.ts b/public/app/features/variables/custom/actions.test.ts index c6508944b0d..9df0916264d 100644 --- a/public/app/features/variables/custom/actions.test.ts +++ b/public/app/features/variables/custom/actions.test.ts @@ -4,9 +4,10 @@ import { createCustomVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { createCustomOptionsFromQuery } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('custom actions', () => { variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -22,6 +23,7 @@ describe('custom actions', () => { const variable: CustomVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'custom', name: 'Custom', @@ -49,12 +51,14 @@ describe('custom actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateCustomVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateCustomVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createCustomOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createCustomOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/custom/actions.ts b/public/app/features/variables/custom/actions.ts index bc3ed3905a8..557d2044dca 100644 --- a/public/app/features/variables/custom/actions.ts +++ b/public/app/features/variables/custom/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createCustomOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateCustomVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateCustomVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createCustomOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createCustomOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/custom/adapter.ts b/public/app/features/variables/custom/adapter.ts index b8aed7172cc..47af02927bc 100644 --- a/public/app/features/variables/custom/adapter.ts +++ b/public/app/features/variables/custom/adapter.ts @@ -6,8 +6,7 @@ import { VariableAdapter } from '../adapters'; import { customVariableReducer, initialCustomVariableModelState } from './reducer'; import { CustomVariableEditor } from './CustomVariableEditor'; import { updateCustomVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { isAllVariable } from '../utils'; +import { isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -24,16 +23,16 @@ export const createCustomVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateCustomVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/custom/reducer.test.ts b/public/app/features/variables/custom/reducer.test.ts index 0cf66830900..39f0ef09815 100644 --- a/public/app/features/variables/custom/reducer.test.ts +++ b/public/app/features/variables/custom/reducer.test.ts @@ -1,11 +1,13 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createCustomOptionsFromQuery, customVariableReducer } from './reducer'; import { createCustomVariableAdapter } from './adapter'; import { CustomVariableModel } from '../types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; describe('customVariableReducer', () => { const adapter = createCustomVariableAdapter(); diff --git a/public/app/features/variables/custom/reducer.ts b/public/app/features/variables/custom/reducer.ts index a579f412f96..9a897030c30 100644 --- a/public/app/features/variables/custom/reducer.ts +++ b/public/app/features/variables/custom/reducer.ts @@ -1,8 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialCustomVariableModelState: CustomVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx index 0be82c77056..51f9a827f71 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx @@ -1,23 +1,38 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { SelectableValue } from '@grafana/data'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { DataSourceVariableModel, VariableWithMultiSupport } from '../types'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; +import { initialVariableEditorState } from '../editor/reducer'; import { initDataSourceVariableEditor } from './actions'; import { StoreState } from '../../../types'; import { changeVariableMultiValue } from '../state/actions'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; -import { SelectableValue } from '@grafana/data'; import { VariableTextField } from '../editor/VariableTextField'; +import { getVariablesState } from '../state/selectors'; import { selectors } from '@grafana/e2e-selectors'; import { getDatasourceVariableEditorState } from '../editor/selectors'; -const mapStateToProps = (state: StoreState) => ({ - extended: getDatasourceVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { + variable: { rootStateKey }, + } = ownProps; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return { + extended: getDatasourceVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + return { + extended: getDatasourceVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initDataSourceVariableEditor, @@ -32,7 +47,13 @@ type Props = OwnProps & ConnectedProps; export class DataSourceVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initDataSourceVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initDataSourceVariableEditor(rootStateKey); } onRegExChange = (event: FormEvent) => { diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index d8226da37ef..67f62788eae 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -2,7 +2,6 @@ import { DataSourceInstanceSettings } from '@grafana/data'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { variableAdapters } from '../adapters'; import { createDataSourceVariableAdapter } from './adapter'; import { @@ -16,6 +15,8 @@ import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { changeVariableEditorExtended } from '../editor/reducer'; import { datasourceBuilder } from '../shared/testing/builders'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; interface Args { sources?: DataSourceInstanceSettings[]; @@ -27,7 +28,7 @@ function getTestContext({ sources = [], query, regex }: Args = {}) { const getListMock = jest.fn().mockReturnValue(sources); const getDatasourceSrvMock = jest.fn().mockReturnValue({ getList: getListMock }); const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock }; - const datasource = datasourceBuilder().withId('0').withQuery(query).withRegEx(regex).build(); + const datasource = datasourceBuilder().withId('0').withRootStateKey('key').withQuery(query).withRegEx(regex).build(); return { getListMock, getDatasourceSrvMock, dependencies, datasource }; } @@ -51,27 +52,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: undefined as unknown as RegExp, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: undefined as unknown as RegExp, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'first-name', value: 'first-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'first-name', value: 'first-name', selected: false } } + ) ) ) ); @@ -99,27 +109,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: /.*(second-name).*/, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: /.*(second-name).*/, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'second-name', value: 'second-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'second-name', value: 'second-name', selected: false } } + ) ) ) ); @@ -143,14 +162,17 @@ describe('data source actions', () => { reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initDataSourceVariableEditor(dependencies)) + .whenActionIsDispatched(initDataSourceVariableEditor('key', dependencies)) .thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ - dataSourceTypes: [ - { text: '', value: '' }, - { text: 'mock-data-name', value: 'mock-data-id' }, - ], - }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ + dataSourceTypes: [ + { text: '', value: '' }, + { text: 'mock-data-name', value: 'mock-data-id' }, + ], + }) + ) ); expect(getListMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/datasource/actions.ts b/public/app/features/variables/datasource/actions.ts index f7a9b031828..dc7b34cdd75 100644 --- a/public/app/features/variables/datasource/actions.ts +++ b/public/app/features/variables/datasource/actions.ts @@ -2,7 +2,7 @@ import { chain } from 'lodash'; import { getTemplateSrv } from '@grafana/runtime'; import { stringToJsRegex } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createDataSourceOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -10,6 +10,8 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getVariable } from '../state/selectors'; import { DataSourceVariableModel } from '../types'; import { changeVariableEditorExtended } from '../editor/reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface DataSourceVariableActionDependencies { getDatasourceSrv: typeof getDatasourceSrv; @@ -17,12 +19,13 @@ export interface DataSourceVariableActionDependencies { export const updateDataSourceVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } ): ThunkResult => async (dispatch, getState) => { + const { rootStateKey } = identifier; const sources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: false }); - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); let regex; if (variableInState.regex) { @@ -30,12 +33,15 @@ export const updateDataSourceVariableOptions = regex = stringToJsRegex(regex); } - dispatch(createDataSourceOptions(toVariablePayload(identifier, { sources, regex }))); + dispatch(toKeyedAction(rootStateKey, createDataSourceOptions(toVariablePayload(identifier, { sources, regex })))); await dispatch(validateVariableSelectionState(identifier)); }; export const initDataSourceVariableEditor = - (dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv }): ThunkResult => + ( + key: string, + dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } + ): ThunkResult => (dispatch) => { const dataSources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: true }); const dataSourceTypes = chain(dataSources) @@ -47,5 +53,5 @@ export const initDataSourceVariableEditor = dataSourceTypes.unshift({ text: '', value: '' }); - dispatch(changeVariableEditorExtended({ dataSourceTypes })); + dispatch(toKeyedAction(key, changeVariableEditorExtended({ dataSourceTypes }))); }; diff --git a/public/app/features/variables/datasource/adapter.ts b/public/app/features/variables/datasource/adapter.ts index a3c8171558a..9b4da36805a 100644 --- a/public/app/features/variables/datasource/adapter.ts +++ b/public/app/features/variables/datasource/adapter.ts @@ -4,10 +4,9 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { dataSourceVariableReducer, initialDataSourceVariableModelState } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { DataSourceVariableEditor } from './DataSourceVariableEditor'; import { updateDataSourceVariableOptions } from './actions'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -27,16 +26,16 @@ export const createDataSourceVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateDataSourceVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return { ...rest, options: [] }; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/datasource/reducer.test.ts b/public/app/features/variables/datasource/reducer.test.ts index 7fc17be7d41..dce1a1ed93f 100644 --- a/public/app/features/variables/datasource/reducer.test.ts +++ b/public/app/features/variables/datasource/reducer.test.ts @@ -1,14 +1,15 @@ +import { cloneDeep } from 'lodash'; import { DataSourceInstanceSettings } from '@grafana/data'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createDataSourceOptions, dataSourceVariableReducer } from './reducer'; import { DataSourceVariableModel } from '../types'; import { getVariableTestContext } from '../state/helpers'; -import { cloneDeep } from 'lodash'; import { createDataSourceVariableAdapter } from './adapter'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toVariablePayload } from '../utils'; describe('dataSourceVariableReducer', () => { const adapter = createDataSourceVariableAdapter(); diff --git a/public/app/features/variables/datasource/reducer.ts b/public/app/features/variables/datasource/reducer.ts index 1a6cb0619d3..3e2370dd495 100644 --- a/public/app/features/variables/datasource/reducer.ts +++ b/public/app/features/variables/datasource/reducer.ts @@ -2,8 +2,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialDataSourceVariableModelState: DataSourceVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/editor/SelectionOptionsEditor.tsx b/public/app/features/variables/editor/SelectionOptionsEditor.tsx index 00ff36f0966..d1e2688cc40 100644 --- a/public/app/features/variables/editor/SelectionOptionsEditor.tsx +++ b/public/app/features/variables/editor/SelectionOptionsEditor.tsx @@ -4,14 +4,15 @@ import { selectors } from '@grafana/e2e-selectors'; import { VariableWithMultiSupport } from '../types'; import { VariableEditorProps } from './types'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableSectionHeader } from './VariableSectionHeader'; import { VariableSwitchField } from './VariableSwitchField'; import { VariableTextField } from './VariableTextField'; +import { toKeyedVariableIdentifier } from '../utils'; export interface SelectionOptionsEditorProps extends VariableEditorProps { - onMultiChanged: (identifier: VariableIdentifier, value: boolean) => void; + onMultiChanged: (identifier: KeyedVariableIdentifier, value: boolean) => void; } export const SelectionOptionsEditor: FunctionComponent = ({ @@ -21,7 +22,7 @@ export const SelectionOptionsEditor: FunctionComponent { const onMultiChanged = useCallback( (event: ChangeEvent) => { - onMultiChangedProps(toVariableIdentifier(variable), event.target.checked); + onMultiChangedProps(toKeyedVariableIdentifier(variable), event.target.checked); }, [onMultiChangedProps, variable] ); diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 6a82dd09b4a..745e368a37c 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -1,36 +1,61 @@ import React, { MouseEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { Icon, LinkButton } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { StoreState } from '../../../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { StoreState, ThunkDispatch } from '../../../types'; import { VariableEditorEditor } from './VariableEditorEditor'; -import { connect, ConnectedProps } from 'react-redux'; -import { getEditorVariables } from '../state/selectors'; +import { getEditorVariables, getVariablesState } from '../state/selectors'; import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer'; import { VariableEditorList } from './VariableEditorList'; import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - variables: getEditorVariables(state), - idInEditor: state.templating.editor.id, - dashboard: state.dashboard.getModel(), - usagesNetwork: state.templating.inspect.usagesNetwork, - usages: state.templating.inspect.usages, -}); - -const mapDispatchToProps = { - changeVariableOrder, - duplicateVariable, - removeVariable, - switchToNewMode, - switchToEditMode, - switchToListMode, +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); + return { + variables: getEditorVariables(uid, state), + idInEditor: templatingState.editor.id, + usagesNetwork: templatingState.inspect.usagesNetwork, + usages: templatingState.inspect.usages, + }; }; -interface OwnProps {} +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ switchToNewMode, switchToEditMode, switchToListMode }, dispatch), + changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })) + ) + ), + duplicateVariable: (identifier: KeyedVariableIdentifier) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })) + ) + ), + removeVariable: (identifier: KeyedVariableIdentifier) => { + dispatch( + toKeyedAction(identifier.rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: true }))) + ); + }, + }; +}; + +interface OwnProps { + dashboard: DashboardModel; +} const connector = connect(mapStateToProps, mapDispatchToProps); @@ -38,32 +63,32 @@ type Props = OwnProps & ConnectedProps; class VariableEditorContainerUnconnected extends PureComponent { componentDidMount(): void { - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); } onChangeToListMode = (event: MouseEvent) => { event.preventDefault(); - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); }; - onEditVariable = (identifier: VariableIdentifier) => { + onEditVariable = (identifier: KeyedVariableIdentifier) => { this.props.switchToEditMode(identifier); }; onNewVariable = () => { - this.props.switchToNewMode(); + this.props.switchToNewMode(this.props.dashboard.uid); }; - onChangeVariableOrder = (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => { - this.props.changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })); + onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => { + this.props.changeVariableOrder(identifier, fromIndex, toIndex); }; - onDuplicateVariable = (identifier: VariableIdentifier) => { - this.props.duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })); + onDuplicateVariable = (identifier: KeyedVariableIdentifier) => { + this.props.duplicateVariable(identifier); }; - onRemoveVariable = (identifier: VariableIdentifier) => { - this.props.removeVariable(toVariablePayload(identifier, { reIndex: true })); + onRemoveVariable = (identifier: KeyedVariableIdentifier) => { + this.props.removeVariable(identifier); }; render() { @@ -117,7 +142,7 @@ class VariableEditorContainerUnconnected extends PureComponent { {!variableToEdit && this.props.variables.length > 0 && ( )} - {variableToEdit && } + {variableToEdit && } ); } diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index a1e6455f1e3..58441e2eff1 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -1,20 +1,17 @@ import React, { FormEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { isEqual } from 'lodash'; import { AppEvents, LoadingState, SelectableValue, VariableType } from '@grafana/data'; import { Button, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { variableAdapters } from '../adapters'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { VariableHide, VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { VariableHide } from '../types'; import { appEvents } from '../../../core/core'; import { VariableValuesPreview } from './VariableValuesPreview'; import { changeVariableName, onEditorUpdate, variableEditorMount, variableEditorUnMount } from './actions'; -import { MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { StoreState } from '../../../types'; -import { VariableEditorState } from './reducer'; -import { getVariable } from '../state/selectors'; -import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; import { OnPropChangeArguments } from './types'; import { changeVariableProp, changeVariableType } from '../state/sharedReducer'; import { updateOptions } from '../state/actions'; @@ -23,27 +20,41 @@ import { VariableSectionHeader } from './VariableSectionHeader'; import { hasOptions } from '../guard'; import { VariableTypeSelect } from './VariableTypeSelect'; import { VariableHideSelect } from './VariableHideSelect'; +import { getVariable, getVariablesState } from '../state/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { StoreState, ThunkDispatch } from '../../../types'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; + +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({ + editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor, + variable: getVariable(ownProps.identifier, state, false), // we could be renaming a variable and we don't want this to throw +}); + +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators( + { variableEditorMount, variableEditorUnMount, changeVariableName, onEditorUpdate, updateOptions }, + dispatch + ), + changeVariableProp: (identifier: KeyedVariableIdentifier, propName: string, propValue: any) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName, propValue })) + ) + ), + changeVariableType: (identifier: KeyedVariableIdentifier, newType: VariableType) => + dispatch(toKeyedAction(identifier.rootStateKey, changeVariableType(toVariablePayload(identifier, { newType })))), + }; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); export interface OwnProps { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; } -interface ConnectedProps { - editor: VariableEditorState; - variable: VariableModel; -} - -interface DispatchProps { - variableEditorMount: typeof variableEditorMount; - variableEditorUnMount: typeof variableEditorUnMount; - changeVariableName: typeof changeVariableName; - changeVariableProp: typeof changeVariableProp; - onEditorUpdate: typeof onEditorUpdate; - changeVariableType: typeof changeVariableType; - updateOptions: typeof updateOptions; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; +type Props = OwnProps & ConnectedProps; export class VariableEditorEditorUnConnected extends PureComponent { componentDidMount(): void { @@ -71,35 +82,26 @@ export class VariableEditorEditorUnConnected extends PureComponent { if (!option.value) { return; } - this.props.changeVariableType(toVariablePayload(this.props.identifier, { newType: option.value })); + this.props.changeVariableType(this.props.identifier, option.value); }; onLabelChange = (event: FormEvent) => { event.preventDefault(); - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'label', event.currentTarget.value); }; onDescriptionChange = (event: FormEvent) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'description', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'description', event.currentTarget.value); }; onHideChange = (option: SelectableValue) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { - propName: 'hide', - propValue: option.value, - }) - ); + this.props.changeVariableProp(this.props.identifier, 'hide', option.value); }; onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => { - this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue })); + this.props.changeVariableProp(this.props.identifier, propName, propValue); if (updateOptions) { - await this.props.updateOptions(toVariableIdentifier(this.props.variable)); + await this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable)); } }; @@ -191,23 +193,4 @@ export class VariableEditorEditorUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state, ownProps) => ({ - editor: state.templating.editor, - variable: getVariable(ownProps.identifier.id, state, false), // we could be renaming a variable and we don't want this to throw -}); - -const mapDispatchToProps: MapDispatchToProps = { - variableEditorMount, - variableEditorUnMount, - changeVariableName, - changeVariableProp, - onEditorUpdate, - changeVariableType, - updateOptions, -}; - -export const VariableEditorEditor = connectWithStore( - VariableEditorEditorUnConnected, - mapStateToProps, - mapDispatchToProps -); +export const VariableEditorEditor = connector(VariableEditorEditorUnConnected); diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index b6f27b4fe9c..c3ac0e585cd 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -4,7 +4,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { VariableModel } from '../types'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { VariableEditorListRow } from './VariableEditorListRow'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -14,10 +14,10 @@ export interface Props { usages: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; onAdd: () => void; - onEdit: (identifier: VariableIdentifier) => void; - onChangeOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onChangeOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorList({ diff --git a/public/app/features/variables/editor/VariableEditorListRow.tsx b/public/app/features/variables/editor/VariableEditorListRow.tsx index 6d117fdcd1a..68b589cbdd9 100644 --- a/public/app/features/variables/editor/VariableEditorListRow.tsx +++ b/public/app/features/variables/editor/VariableEditorListRow.tsx @@ -8,18 +8,19 @@ import { reportInteraction } from '@grafana/runtime'; import { getVariableUsages, UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { hasOptions, isAdHoc, isQuery } from '../guard'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; import { VariableModel } from '../types'; +import { toKeyedVariableIdentifier } from '../utils'; export interface VariableEditorListRowProps { index: number; variable: VariableModel; usageTree: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; - onEdit: (identifier: VariableIdentifier) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorListRow({ @@ -36,7 +37,7 @@ export function VariableEditorListRow({ const definition = getDefinition(variable); const usages = getVariableUsages(variable.id, usageTree); const passed = usages > 0 || isAdHoc(variable); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); return ( diff --git a/public/app/features/variables/editor/actions.ts b/public/app/features/variables/editor/actions.ts index 1aa5af6f93e..55d9b12c2a4 100644 --- a/public/app/features/variables/editor/actions.ts +++ b/public/app/features/variables/editor/actions.ts @@ -1,5 +1,5 @@ import { ThunkResult } from '../../../types'; -import { getEditorVariables, getNewVariableIndex, getVariable, getVariables } from '../state/selectors'; +import { getEditorVariables, getNewVariableIndex, getVariable, getVariablesByKey } from '../state/selectors'; import { changeVariableNameFailed, changeVariableNameSucceeded, @@ -9,7 +9,7 @@ import { variableEditorUnMounted, } from './reducer'; import { variableAdapters } from '../adapters'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { AddVariable, KeyedVariableIdentifier, VariableIdentifier } from '../state/types'; import { cloneDeep } from 'lodash'; import { VariableType } from '@grafana/data'; import { addVariable, removeVariable } from '../state/sharedReducer'; @@ -17,28 +17,33 @@ import { updateOptions } from '../state/actions'; import { VariableModel } from '../types'; import { initInspect } from '../inspect/reducer'; import { createUsagesNetwork, transformUsagesToNetwork } from '../inspect/utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - dispatch(variableEditorMounted({ name: getVariable(identifier.id).name })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorMounted({ name: getVariable(identifier).name }))); }; }; -export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorUnMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - dispatch(variableEditorUnMounted(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorUnMounted(toVariablePayload(identifier)))); }; }; -export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult => { +export const onEditorUpdate = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { await dispatch(updateOptions(identifier)); - dispatch(switchToListMode()); + dispatch(switchToListMode(identifier.rootStateKey)); }; }; -export const changeVariableName = (identifier: VariableIdentifier, newName: string): ThunkResult => { +export const changeVariableName = (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => { return (dispatch, getState) => { + const { id, rootStateKey: uid } = identifier; let errorText = null; if (!newName.match(/^(?!__).*$/)) { errorText = "Template names cannot begin with '__', that's reserved for Grafana's global variables"; @@ -48,15 +53,15 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri errorText = 'Only word and digit characters are allowed in variable names'; } - const variables = getVariables(getState()); - const foundVariables = variables.filter((v) => v.name === newName && v.id !== identifier.id); + const variables = getVariablesByKey(uid, getState()); + const foundVariables = variables.filter((v) => v.name === newName && v.id !== id); if (foundVariables.length) { errorText = 'Variable with the same name already exists'; } if (errorText) { - dispatch(changeVariableNameFailed({ newName, errorText })); + dispatch(toKeyedAction(uid, changeVariableNameFailed({ newName, errorText }))); return; } @@ -65,54 +70,61 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri }; export const completeChangeVariableName = - (identifier: VariableIdentifier, newName: string): ThunkResult => + (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => (dispatch, getState) => { - const originalVariable = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const originalVariable = getVariable(identifier, getState()); if (originalVariable.name === newName) { - dispatch(changeVariableNameSucceeded(toVariablePayload(identifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(identifier, { newName })))); return; } const model = { ...cloneDeep(originalVariable), name: newName, id: newName }; const global = originalVariable.global; const index = originalVariable.index; - const renamedIdentifier = toVariableIdentifier(model); + const renamedIdentifier = toKeyedVariableIdentifier(model); - dispatch(addVariable(toVariablePayload(renamedIdentifier, { global, index, model }))); - dispatch(changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, addVariable(toVariablePayload(renamedIdentifier, { global, index, model })))); + dispatch( + toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))) + ); dispatch(switchToEditMode(renamedIdentifier)); - dispatch(removeVariable(toVariablePayload(identifier, { reIndex: false }))); + dispatch(toKeyedAction(rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: false })))); }; export const switchToNewMode = - (type: VariableType = 'query'): ThunkResult => + (key: string, type: VariableType = 'query'): ThunkResult => (dispatch, getState) => { - const id = getNextAvailableId(type, getVariables(getState())); - const identifier = { type, id }; + const id = getNextAvailableId(type, getVariablesByKey(key, getState())); + const identifier: VariableIdentifier = { type, id }; const global = false; - const index = getNewVariableIndex(getState()); - const model = cloneDeep(variableAdapters.get(type).initialState); + const index = getNewVariableIndex(key, getState()); + const model: VariableModel = cloneDeep(variableAdapters.get(type).initialState); model.id = id; model.name = id; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); - dispatch(setIdInEditor({ id: identifier.id })); + model.rootStateKey = key; + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); + dispatch(toKeyedAction(key, setIdInEditor({ id: identifier.id }))); }; export const switchToEditMode = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch) => { - dispatch(setIdInEditor({ id: identifier.id })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, setIdInEditor({ id: identifier.id }))); }; -export const switchToListMode = (): ThunkResult => (dispatch, getState) => { - dispatch(clearIdInEditor()); - const state = getState(); - const variables = getEditorVariables(state); - const dashboard = state.dashboard.getModel(); - const { usages } = createUsagesNetwork(variables, dashboard); - const usagesNetwork = transformUsagesToNetwork(usages); +export const switchToListMode = + (key: string): ThunkResult => + (dispatch, getState) => { + dispatch(toKeyedAction(key, clearIdInEditor())); + const state = getState(); + const variables = getEditorVariables(key, state); + const dashboard = state.dashboard.getModel(); + const { usages } = createUsagesNetwork(variables, dashboard); + const usagesNetwork = transformUsagesToNetwork(usages); - dispatch(initInspect({ usages, usagesNetwork })); -}; + dispatch(toKeyedAction(key, initInspect({ usages, usagesNetwork }))); + }; export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string { let counter = 0; diff --git a/public/app/features/variables/editor/reducer.test.ts b/public/app/features/variables/editor/reducer.test.ts index 1fa8002a659..e1ed467bb3f 100644 --- a/public/app/features/variables/editor/reducer.test.ts +++ b/public/app/features/variables/editor/reducer.test.ts @@ -14,7 +14,7 @@ import { VariableEditorState, variableEditorUnMounted, } from './reducer'; -import { toVariablePayload } from '../state/types'; +import { toVariablePayload } from '../utils'; describe('variableEditorReducer', () => { describe('when setIdInEditor is dispatched', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.test.ts b/public/app/features/variables/getAllVariableValuesForUrl.test.ts index 7eb44ac7be0..dbd04260212 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.test.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.test.ts @@ -4,6 +4,8 @@ import { createQueryVariableAdapter } from './query/adapter'; import { getVariablesUrlParams } from './getAllVariableValuesForUrl'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; +const key = 'key'; + describe('getAllVariableValuesForUrl', () => { beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); @@ -12,10 +14,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { return this.current.value; @@ -34,9 +37,10 @@ describe('getAllVariableValuesForUrl', () => { describe('skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: 'value' }, getValueForUrl: function () { @@ -56,10 +60,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value with skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { @@ -78,7 +83,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value and scopedVars', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should set scoped value as url params', () => { @@ -91,7 +98,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should not set scoped value as url params', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.ts b/public/app/features/variables/getAllVariableValuesForUrl.ts index 4cfbfd55d33..e775394f287 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.ts @@ -6,7 +6,6 @@ export function getVariablesUrlParams(scopedVars?: ScopedVars): UrlQueryMap { const params: UrlQueryMap = {}; const variables = getTemplateSrv().getVariables(); - // console.log(variables) for (let i = 0; i < variables.length; i++) { const variable = variables[i]; if (scopedVars && scopedVars[variable.name] !== void 0) { diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 8e320fa6b18..007caacc7a7 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,6 +1,5 @@ import { getRootReducer, RootReducerType } from '../state/helpers'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions'; import { createIntervalOptions } from './reducer'; import { @@ -20,25 +19,37 @@ import { notifyApp } from '../../../core/actions'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { variablesInitTransaction } from '../state/transactionReducer'; import { afterEach, beforeEach } from '../../../../test/lib/common'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('interval actions', () => { variableAdapters.setInit(() => [createIntervalVariableAdapter()]); describe('when updateIntervalVariableOptions is dispatched', () => { it('then correct actions are dispatched', async () => { - const interval = intervalBuilder().withId('0').withQuery('1s,1m,1h,1d').withAuto(false).build(); + const interval = intervalBuilder() + .withId('0') + .withRootStateKey('key') + .withQuery('1s,1m,1h,1d') + .withAuto(false) + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) - .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsShouldEqual( - createIntervalOptions({ type: 'interval', id: '0', data: undefined }), - setCurrentVariableValue({ - type: 'interval', - id: '0', - data: { option: { text: '1s', value: '1s', selected: false } }, - }) + toKeyedAction('key', createIntervalOptions({ type: 'interval', id: '0', data: undefined })), + toKeyedAction( + 'key', + setCurrentVariableValue({ + type: 'interval', + id: '0', + data: { option: { text: '1s', value: '1s', selected: false } }, + }) + ) ); }); }); @@ -67,6 +78,7 @@ describe('interval actions', () => { it('then an notifyApp action should be dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -74,21 +86,26 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 4; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(interval))); - expect(dispatchedActions[1]).toEqual(createIntervalOptions(toVariablePayload(interval))); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(interval)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', createIntervalOptions(toVariablePayload(interval)))); expect(dispatchedActions[2]).toEqual( - variableStateFailed( - toVariablePayload(interval, { - error: new Error( - 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' - ), - }) + toKeyedAction( + 'key', + variableStateFailed( + toVariablePayload(interval, { + error: new Error( + 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' + ), + }) + ) ) ); @@ -107,6 +124,7 @@ describe('interval actions', () => { it('then no actions are dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -115,9 +133,9 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenNoActionsWhereDispatched(); }); @@ -127,7 +145,7 @@ describe('interval actions', () => { describe('when updateAutoValue is dispatched', () => { describe('and auto is false', () => { it('then no dependencies are called', async () => { - const interval = intervalBuilder().withId('0').withAuto(false).build(); + const interval = intervalBuilder().withId('0').withRootStateKey('key').withAuto(false).build(); const dependencies: UpdateAutoValueDependencies = { calculateInterval: jest.fn(), @@ -151,9 +169,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(0); expect(dependencies.getTimeSrv().timeRange).toHaveBeenCalledTimes(0); @@ -165,6 +183,7 @@ describe('interval actions', () => { it('then correct dependencies are called', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withName('intervalName') .withAuto(true) .withAutoCount(33) @@ -195,9 +214,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(1); expect(dependencies.calculateInterval).toHaveBeenCalledWith( diff --git a/public/app/features/variables/interval/actions.ts b/public/app/features/variables/interval/actions.ts index c75a724e8dd..bc3f4d8d521 100644 --- a/public/app/features/variables/interval/actions.ts +++ b/public/app/features/variables/interval/actions.ts @@ -1,6 +1,6 @@ import { rangeUtil } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createIntervalOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -8,11 +8,14 @@ import { getVariable } from '../state/selectors'; import { IntervalVariableModel } from '../types'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export const updateIntervalVariableOptions = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch) => { - await dispatch(createIntervalOptions(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createIntervalOptions(toVariablePayload(identifier)))); await dispatch(updateAutoValue(identifier)); await dispatch(validateVariableSelectionState(identifier)); }; @@ -25,7 +28,7 @@ export interface UpdateAutoValueDependencies { export const updateAutoValue = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: UpdateAutoValueDependencies = { calculateInterval: rangeUtil.calculateInterval, getTimeSrv: getTimeSrv, @@ -33,7 +36,7 @@ export const updateAutoValue = } ): ThunkResult => (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.auto) { const res = dependencies.calculateInterval( dependencies.getTimeSrv().timeRange(), diff --git a/public/app/features/variables/interval/adapter.ts b/public/app/features/variables/interval/adapter.ts index 36402c2e866..69e523836a6 100644 --- a/public/app/features/variables/interval/adapter.ts +++ b/public/app/features/variables/interval/adapter.ts @@ -4,10 +4,10 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { initialIntervalVariableModelState, intervalVariableReducer } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { IntervalVariableEditor } from './IntervalVariableEditor'; import { updateAutoValue, updateIntervalVariableOptions } from './actions'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createIntervalVariableAdapter = (): VariableAdapter => { return { @@ -22,18 +22,18 @@ export const createIntervalVariableAdapter = (): VariableAdapter { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateIntervalVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/interval/reducer.test.ts b/public/app/features/variables/interval/reducer.test.ts index 3faf98a3281..e5b04df6ae8 100644 --- a/public/app/features/variables/interval/reducer.test.ts +++ b/public/app/features/variables/interval/reducer.test.ts @@ -1,11 +1,12 @@ import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createIntervalVariableAdapter } from './adapter'; import { IntervalVariableModel } from '../types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createIntervalOptions, intervalVariableReducer } from './reducer'; +import { toVariablePayload } from '../utils'; describe('intervalVariableReducer', () => { const adapter = createIntervalVariableAdapter(); diff --git a/public/app/features/variables/interval/reducer.ts b/public/app/features/variables/interval/reducer.ts index 09e65357a8a..faadce99a6a 100644 --- a/public/app/features/variables/interval/reducer.ts +++ b/public/app/features/variables/interval/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, IntervalVariableModel, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { map } from 'lodash'; +import { getInstanceState } from '../state/selectors'; export const initialIntervalVariableModelState: IntervalVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx index 075908f023d..36a1a50e17e 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx @@ -9,7 +9,8 @@ import { VariablePickerProps } from '../types'; import { QueryVariableModel, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { queryBuilder } from '../../shared/testing/builders'; import { optionPickerFactory } from './OptionsPicker'; -import { initialState, OptionsPickerState } from './reducer'; +import { initialOptionPickerState, OptionsPickerState } from './reducer'; +import { getPreloadedState } from '../../state/helpers'; interface Args { pickerState?: Partial; @@ -18,6 +19,7 @@ interface Args { const defaultVariable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) @@ -35,17 +37,16 @@ function setupTestContext({ pickerState = {}, variable = {} }: Args = {}) { onVariableChange, }; const Picker = optionPickerFactory(); - const optionsPicker: OptionsPickerState = { ...initialState, ...pickerState }; + const optionsPicker: OptionsPickerState = { ...initialOptionPickerState, ...pickerState }; const dispatch = jest.fn(); const subscribe = jest.fn(); - const getState = jest.fn().mockReturnValue({ - templating: { - variables: { - [v.id]: { ...v }, - }, - optionsPicker, + const templatingState = { + variables: { + [v.id]: { ...v }, }, - }); + optionsPicker, + }; + const getState = jest.fn().mockReturnValue(getPreloadedState('key', templatingState)); const store: any = { getState, dispatch, subscribe }; const { rerender } = render( diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx index 9c082a44658..c33a9afc05b 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx @@ -1,36 +1,58 @@ import React, { ComponentType, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import { ClickOutsideWrapper } from '@grafana/ui'; import { LoadingState } from '@grafana/data'; -import { StoreState } from 'app/types'; +import { StoreState, ThunkDispatch } from 'app/types'; import { VariableInput } from '../shared/VariableInput'; import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, openOptions } from './actions'; -import { OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; +import { initialOptionPickerState, OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { VariableOptions } from '../shared/VariableOptions'; import { isMulti } from '../../guard'; -import { VariablePickerProps } from '../types'; +import { NavigationKey, VariablePickerProps } from '../types'; import { formatVariableLabel } from '../../shared/formatVariable'; -import { toVariableIdentifier } from '../../state/types'; +import { KeyedVariableIdentifier } from '../../state/types'; import { getVariableQueryRunner } from '../../query/VariableQueryRunner'; import { VariableLink } from '../shared/VariableLink'; +import { getVariablesState } from '../../state/selectors'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier } from '../../utils'; export const optionPickerFactory = (): ComponentType< VariablePickerProps > => { - const mapDispatchToProps = { - openOptions, - commitChangesToVariable, - filterOrSearchOptions, - toggleAllOptions, - toggleOption, - navigateOptions, + const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ openOptions, commitChangesToVariable, navigateOptions }, dispatch), + filterOrSearchOptions: (identifier: KeyedVariableIdentifier, filter = '') => { + dispatch(filterOrSearchOptions(identifier, filter)); + }, + toggleAllOptions: (identifier: KeyedVariableIdentifier) => + dispatch(toKeyedAction(identifier.rootStateKey, toggleAllOptions())), + toggleOption: ( + identifier: KeyedVariableIdentifier, + option: VariableOption, + clearOthers: boolean, + forceSelect: boolean + ) => dispatch(toKeyedAction(identifier.rootStateKey, toggleOption({ option, clearOthers, forceSelect }))), + }; }; - const mapStateToProps = (state: StoreState) => ({ - picker: state.templating.optionsPicker, - }); + const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('OptionPickerFactory: variable has no rootStateKey'); + return { + picker: initialOptionPickerState, + }; + } + + return { + picker: getVariablesState(rootStateKey, state).optionsPicker, + }; + }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -40,8 +62,15 @@ export const optionPickerFactory = { onShowOptions = () => - this.props.openOptions(toVariableIdentifier(this.props.variable), this.props.onVariableChange); - onHideOptions = () => this.props.commitChangesToVariable(this.props.onVariableChange); + this.props.openOptions(toKeyedVariableIdentifier(this.props.variable), this.props.onVariableChange); + onHideOptions = () => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.commitChangesToVariable(this.props.variable.rootStateKey, this.props.onVariableChange); + }; onToggleOption = (option: VariableOption, clearOthers: boolean) => { const toggleFunc = @@ -52,12 +81,29 @@ export const optionPickerFactory = { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); this.onHideOptions(); }; onToggleMultiValueVariable = (option: VariableOption, clearOthers: boolean) => { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); + }; + + onToggleAllOptions = () => { + this.props.toggleAllOptions(toKeyedVariableIdentifier(this.props.variable)); + }; + + onFilterOrSearchOptions = (filter: string) => { + this.props.filterOrSearchOptions(toKeyedVariableIdentifier(this.props.variable), filter); + }; + + onNavigate = (key: NavigationKey, clearOthers: boolean) => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.navigateOptions(this.props.variable.rootStateKey, key, clearOthers); }; render() { @@ -87,7 +133,7 @@ export const optionPickerFactory = { - getVariableQueryRunner().cancelRequest(toVariableIdentifier(this.props.variable)); + getVariableQueryRunner().cancelRequest(toKeyedVariableIdentifier(this.props.variable)); }; renderOptions(picker: OptionsPickerState) { @@ -97,15 +143,15 @@ export const optionPickerFactory = Promise.resolve([])), @@ -59,9 +60,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['A']), @@ -70,9 +73,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -91,12 +97,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, false)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, false)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -114,12 +124,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -133,14 +147,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[2], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[2], forceSelect: false, clearOthers })) + ); }); }); @@ -154,15 +172,19 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: false, clearOthers })) + ); }); }); @@ -176,13 +198,15 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['B']), @@ -191,11 +215,14 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: options[1], forceSelect: true, clearOthers }), - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: true, clearOthers })), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': ['B'] }); }); @@ -209,11 +236,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(filterOrSearchOptions(filter), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), filter), true); - tester.thenDispatchedActionsShouldEqual(updateSearchQuery(filter), updateOptionsAndFilter(variable.options)); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateSearchQuery(filter)), + toKeyedAction('key', updateOptionsAndFilter(variable.options)) + ); }); }); @@ -221,26 +253,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -248,26 +279,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState, id: variable.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variable.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -275,6 +305,7 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variableInPickerState = queryBuilder() .withId('query1') + .withRootStateKey('key') .withName('query1') .withMulti() .withCurrent(['A', 'C']) @@ -283,31 +314,33 @@ describe('options picker actions', () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - [variableInPickerState.id]: { ...variableInPickerState }, - }, - optionsPicker: { ...initialState, id: variableInPickerState.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, + [variableInPickerState.id]: { ...variableInPickerState }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variableInPickerState.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } }), - changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }), - hideOptions(), - showOptions(variable) + toKeyedAction('key', setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } })), + toKeyedAction( + 'key', + changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', showOptions(variable)) ); }); }); @@ -319,9 +352,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption(['A']), @@ -330,9 +365,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -345,11 +383,13 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -358,10 +398,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -375,12 +418,14 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenActionIsDispatched(filterOrSearchOptions('C')) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'C')) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -389,10 +434,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -406,14 +454,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true); const option = createOption('A'); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -425,25 +477,27 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true) - .whenActionIsDispatched(filterOrSearchOptions('B')) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'B')) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)); const optionA = createOption('A'); const optionBC = createOption('BD'); tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: optionA, forceSelect: false, clearOthers }), - updateSearchQuery('B'), - updateOptionsAndFilter(variable.options), - moveOptionsHighlight(1), - moveOptionsHighlight(1), - toggleOption({ option: optionBC, forceSelect: false, clearOthers }) + toKeyedAction('key', toggleOption({ option: optionA, forceSelect: false, clearOthers })), + toKeyedAction('key', updateSearchQuery('B')), + toKeyedAction('key', updateOptionsAndFilter(variable.options)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', toggleOption({ option: optionBC, forceSelect: false, clearOthers })) ); }); }); @@ -454,6 +508,7 @@ function createMultiVariable(extend?: Partial): QueryVariabl ...initialVariableModelState, type: 'query', id: '0', + rootStateKey: 'key', index: 0, current: createOption([]), options: [], diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.ts b/public/app/features/variables/pickers/OptionsPicker/actions.ts index d6a634689bd..7d25290fb22 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.ts @@ -2,7 +2,7 @@ import { debounce, trim } from 'lodash'; import { StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { variableAdapters } from '../../adapters'; -import { getVariable } from '../../state/selectors'; +import { getVariable, getVariablesState } from '../../state/selectors'; import { NavigationKey } from '../types'; import { hideOptions, @@ -15,50 +15,56 @@ import { updateSearchQuery, } from './reducer'; import { changeVariableProp, setCurrentVariableValue } from '../../state/sharedReducer'; -import { toVariablePayload, VariableIdentifier } from '../../state/types'; -import { containsSearchFilter, getCurrentText } from '../../utils'; +import { KeyedVariableIdentifier } from '../../state/types'; +import { containsSearchFilter, getCurrentText, toVariablePayload } from '../../utils'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; -export const navigateOptions = (key: NavigationKey, clearOthers: boolean): ThunkResult => { +export const navigateOptions = (rootStateKey: string, key: NavigationKey, clearOthers: boolean): ThunkResult => { return async (dispatch, getState) => { if (key === NavigationKey.cancel) { - return await dispatch(commitChangesToVariable()); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.select) { - return dispatch(toggleOptionByHighlight(clearOthers)); + return dispatch(toggleOptionByHighlight(rootStateKey, clearOthers)); } if (key === NavigationKey.selectAndClose) { - dispatch(toggleOptionByHighlight(clearOthers, true)); - return await dispatch(commitChangesToVariable()); + dispatch(toggleOptionByHighlight(rootStateKey, clearOthers, true)); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.moveDown) { - return dispatch(moveOptionsHighlight(1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(1))); } if (key === NavigationKey.moveUp) { - return dispatch(moveOptionsHighlight(-1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(-1))); } return undefined; }; }; -export const filterOrSearchOptions = (searchQuery = ''): ThunkResult => { +export const filterOrSearchOptions = ( + passedIdentifier: KeyedVariableIdentifier, + searchQuery = '' +): ThunkResult => { return async (dispatch, getState) => { - const { id, queryValue } = getState().templating.optionsPicker; - const { query, options } = getVariable(id, getState()); - dispatch(updateSearchQuery(searchQuery)); + const { rootStateKey } = passedIdentifier; + const { id, queryValue } = getVariablesState(rootStateKey, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: rootStateKey, type: 'query' }; + const { query, options } = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, updateSearchQuery(searchQuery))); if (trim(queryValue) === trim(searchQuery)) { return; } if (containsSearchFilter(query)) { - return searchForOptionsWithDebounce(dispatch, getState, searchQuery); + return searchForOptionsWithDebounce(dispatch, getState, searchQuery, rootStateKey); } - return dispatch(updateOptionsAndFilter(options)); + return dispatch(toKeyedAction(rootStateKey, updateOptionsAndFilter(options))); }; }; @@ -68,17 +74,18 @@ const setVariable = async (updated: VariableWithMultiSupport) => { return; }; -export const commitChangesToVariable = (callback?: (updated: any) => void): ThunkResult => { +export const commitChangesToVariable = (key: string, callback?: (updated: any) => void): ThunkResult => { return async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; - const existing = getVariable(picker.id, getState()); + const picker = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id: picker.id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const currentPayload = { option: mapToCurrent(picker) }; const searchQueryPayload = { propName: 'queryValue', propValue: picker.queryValue }; - dispatch(setCurrentVariableValue(toVariablePayload(existing, currentPayload))); - dispatch(changeVariableProp(toVariablePayload(existing, searchQueryPayload))); - const updated = getVariable(picker.id, getState()); - dispatch(hideOptions()); + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(existing, currentPayload)))); + dispatch(toKeyedAction(key, changeVariableProp(toVariablePayload(existing, searchQueryPayload)))); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, hideOptions())); if (getCurrentText(existing) === getCurrentText(updated)) { return; @@ -93,36 +100,43 @@ export const commitChangesToVariable = (callback?: (updated: any) => void): Thun }; export const openOptions = - ({ id }: VariableIdentifier, callback?: (updated: any) => void): ThunkResult => + (identifier: KeyedVariableIdentifier, callback?: (updated: any) => void): ThunkResult => async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; + const { id, rootStateKey: uid } = identifier; + const picker = getVariablesState(uid, getState()).optionsPicker; if (picker.id && picker.id !== id) { - await dispatch(commitChangesToVariable(callback)); + await dispatch(commitChangesToVariable(uid, callback)); } - const variable = getVariable(id, getState()); - dispatch(showOptions(variable)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(uid, showOptions(variable))); }; -export const toggleOptionByHighlight = (clearOthers: boolean, forceSelect = false): ThunkResult => { +export const toggleOptionByHighlight = (key: string, clearOthers: boolean, forceSelect = false): ThunkResult => { return (dispatch, getState) => { - const { highlightIndex, options } = getState().templating.optionsPicker; + const { highlightIndex, options } = getVariablesState(key, getState()).optionsPicker; const option = options[highlightIndex]; - dispatch(toggleOption({ option, forceSelect, clearOthers })); + dispatch(toKeyedAction(key, toggleOption({ option, forceSelect, clearOthers }))); }; }; -const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreState, searchQuery: string) => { +const searchForOptions = async ( + dispatch: ThunkDispatch, + getState: () => StoreState, + searchQuery: string, + key: string +) => { try { - const { id } = getState().templating.optionsPicker; - const existing = getVariable(id, getState()); + const { id } = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const adapter = variableAdapters.get(existing.type); await adapter.updateOptions(existing, searchQuery); - const updated = getVariable(id, getState()); - dispatch(updateOptionsFromSearch(updated.options)); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, updateOptionsFromSearch(updated.options))); } catch (error) { console.error(error); } diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts index e28f8a51792..b706a6c3a83 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts @@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'; import { cleanPickerState, hideOptions, - initialState as optionsPickerInitialState, + initialOptionPickerState as optionsPickerInitialState, moveOptionsHighlight, OPTIONS_LIMIT, optionsPickerReducer, diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.ts index 92d87996682..ce9f009f384 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.ts @@ -21,7 +21,7 @@ export interface OptionsPickerState { multi: boolean; } -export const initialState: OptionsPickerState = { +export const initialOptionPickerState: OptionsPickerState = { id: '', highlightIndex: -1, queryValue: '', @@ -106,7 +106,7 @@ const updateAllSelection = (state: OptionsPickerState): OptionsPickerState => { const optionsPickerSlice = createSlice({ name: 'templating/optionsPicker', - initialState, + initialState: initialOptionPickerState, reducers: { showOptions: (state, action: PayloadAction): OptionsPickerState => { const { query, options } = action.payload; @@ -131,7 +131,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, hideOptions: (state, action: PayloadAction): OptionsPickerState => { - return { ...initialState }; + return { ...initialOptionPickerState }; }, toggleOption: (state, action: PayloadAction): OptionsPickerState => { const { option, clearOthers, forceSelect } = action.payload; @@ -212,7 +212,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, - cleanPickerState: () => initialState, + cleanPickerState: () => initialOptionPickerState, }, }); diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 69c4cd08810..987446dd904 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -10,14 +10,17 @@ import { LegacyVariableQueryEditor } from '../editor/LegacyVariableQueryEditor'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { NEW_VARIABLE_ID } from '../constants'; +import { VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; const setupTestContext = (options: Partial) => { + const variableDefaults: Partial = { rootStateKey: 'key' }; const extended = { VariableQueryEditor: LegacyVariableQueryEditor, dataSource: {} as unknown as DataSourceApi, }; const defaults: Props = { - variable: { ...initialQueryVariableModelState }, + variable: { ...initialQueryVariableModelState, ...variableDefaults }, initQueryVariableEditor: jest.fn(), changeQueryVariableDataSource: jest.fn(), changeQueryVariableQuery: jest.fn(), @@ -47,20 +50,22 @@ jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { }; }); +const defaultIdentifier: KeyedVariableIdentifier = { type: 'query', rootStateKey: 'key', id: NEW_VARIABLE_ID }; + describe('QueryVariableEditor', () => { describe('when the component is mounted', () => { it('then it should call initQueryVariableEditor', () => { const { props } = setupTestContext({}); expect(props.initQueryVariableEditor).toHaveBeenCalledTimes(1); - expect(props.initQueryVariableEditor).toHaveBeenCalledWith({ type: 'query', id: NEW_VARIABLE_ID }); + expect(props.initQueryVariableEditor).toHaveBeenCalledWith(defaultIdentifier); }); }); describe('when the user changes', () => { it.each` fieldName | propName | expectedArgs - ${'query'} | ${'changeQueryVariableQuery'} | ${[{ type: 'query', id: NEW_VARIABLE_ID }, 't', 't']} + ${'query'} | ${'changeQueryVariableQuery'} | ${[defaultIdentifier, 't', 't']} ${'regex'} | ${'onPropChange'} | ${[{ propName: 'regex', propValue: 't', updateOptions: true }]} `( '$fieldName field and tabs away then $propName should be called with correct args', diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index 521748f237f..94ff3cb539b 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -9,9 +9,9 @@ import { DataSourceInstanceSettings, getDataSourceRef, LoadingState, SelectableV import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types'; import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions'; +import { initialVariableEditorState } from '../editor/reducer'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { StoreState } from '../../../types'; -import { toVariableIdentifier } from '../state/types'; import { changeVariableMultiValue } from '../state/actions'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { isLegacyQueryEditor, isQueryEditor } from '../guard'; @@ -20,10 +20,24 @@ import { VariableTextField } from '../editor/VariableTextField'; import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect'; import { QueryVariableSortSelect } from './QueryVariableSortSelect'; import { getQueryVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getQueryVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('QueryVariableEditor: variable has no rootStateKey'); + return { + extended: getQueryVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getQueryVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initQueryVariableEditor, @@ -52,13 +66,13 @@ export class QueryVariableEditorUnConnected extends PureComponent }; async componentDidMount() { - await this.props.initQueryVariableEditor(toVariableIdentifier(this.props.variable)); + await this.props.initQueryVariableEditor(toKeyedVariableIdentifier(this.props.variable)); } componentDidUpdate(prevProps: Readonly): void { if (prevProps.variable.datasource !== this.props.variable.datasource) { this.props.changeQueryVariableDataSource( - toVariableIdentifier(this.props.variable), + toKeyedVariableIdentifier(this.props.variable), this.props.variable.datasource ); } @@ -73,7 +87,7 @@ export class QueryVariableEditorUnConnected extends PureComponent onLegacyQueryChange = async (query: any, definition: string) => { if (this.props.variable.query !== query) { - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; @@ -85,7 +99,7 @@ export class QueryVariableEditorUnConnected extends PureComponent definition = query.query; } - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; diff --git a/public/app/features/variables/query/VariableQueryRunner.test.ts b/public/app/features/variables/query/VariableQueryRunner.test.ts index e212b625f0c..e1670479de2 100644 --- a/public/app/features/variables/query/VariableQueryRunner.test.ts +++ b/public/app/features/variables/query/VariableQueryRunner.test.ts @@ -5,9 +5,13 @@ import { delay } from 'rxjs/operators'; import { UpdateOptionsResults, VariableQueryRunner } from './VariableQueryRunner'; import { queryBuilder } from '../shared/testing/builders'; import { QueryRunner, QueryRunners } from './queryRunners'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { QueryVariableModel } from '../types'; import { updateVariableOptions } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { initialTransactionState } from '../state/transactionReducer'; +import { getPreloadedState } from '../state/helpers'; +import { toKeyedVariableIdentifier } from '../utils'; type DoneCallback = { (...args: any[]): any; @@ -16,7 +20,7 @@ type DoneCallback = { function expectOnResults(args: { runner: VariableQueryRunner; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; done: DoneCallback; expect: (results: UpdateOptionsResults[]) => void; }) { @@ -32,7 +36,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } } }, @@ -40,25 +44,23 @@ function expectOnResults(args: { } function getTestContext(variable?: QueryVariableModel) { - variable = variable ?? queryBuilder().withId('query').build(); const getTimeSrv = jest.fn().mockReturnValue({ timeRange: jest.fn().mockReturnValue(getDefaultTimeRange()), }); + const key = '0123456789'; + variable = variable ?? queryBuilder().withId('query').withRootStateKey(key).withName('query').build(); const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([]) }; - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const searchFilter = undefined; const getTemplatedRegex = jest.fn().mockReturnValue('getTemplatedRegex result'); const dispatch = jest.fn().mockResolvedValue({}); - const getState = jest.fn().mockReturnValue({ - templating: { - transaction: { - uid: '0123456789', - }, - }, + const templatingState = { + transaction: { ...initialTransactionState, uid: key }, variables: { [variable.id]: variable, }, - }); + }; + const getState = jest.fn().mockReturnValue(getPreloadedState(key, templatingState)); const queryRunner: QueryRunner = { type: VariableSupportType.Standard, canRun: jest.fn().mockReturnValue(true), @@ -81,6 +83,7 @@ function getTestContext(variable?: QueryVariableModel) { }); return { + key, identifier, datasource, runner, @@ -100,7 +103,7 @@ function getTestContext(variable?: QueryVariableModel) { describe('VariableQueryRunner', () => { describe('happy case', () => { it('then it should work as expected', (done) => { - const { identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = + const { key, identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = getTestContext(); expectOnResults({ @@ -124,11 +127,14 @@ describe('VariableQueryRunner', () => { // updateVariableOptions and validateVariableSelectionState expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [], templatedRegex: 'getTemplatedRegex result' }, - }) + toKeyedAction( + key, + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [], templatedRegex: 'getTemplatedRegex result' }, + }) + ) ); }, done, diff --git a/public/app/features/variables/query/VariableQueryRunner.ts b/public/app/features/variables/query/VariableQueryRunner.ts index ea17c2ce2ea..866cc92ca85 100644 --- a/public/app/features/variables/query/VariableQueryRunner.ts +++ b/public/app/features/variables/query/VariableQueryRunner.ts @@ -11,8 +11,8 @@ import { ScopedVars, } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; -import { getVariable } from '../state/selectors'; +import { KeyedVariableIdentifier } from '../state/types'; +import { getLastKey, getVariable } from '../state/selectors'; import { QueryVariableModel, VariableRefresh } from '../types'; import { StoreState, ThunkDispatch } from '../../../types'; import { dispatch, getState } from '../../../store/store'; @@ -24,14 +24,14 @@ import { runRequest } from '../../query/state/runRequest'; import { toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; interface UpdateOptionsArgs { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; datasource: DataSourceApi; searchFilter?: string; } export interface UpdateOptionsResults { state: LoadingState; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; error?: any; cancelled?: boolean; } @@ -49,7 +49,7 @@ interface VariableQueryRunnerArgs { export class VariableQueryRunner { private readonly updateOptionsRequests: Subject; private readonly updateOptionsResults: Subject; - private readonly cancelRequests: Subject<{ identifier: VariableIdentifier }>; + private readonly cancelRequests: Subject<{ identifier: KeyedVariableIdentifier }>; private readonly subscription: Unsubscribable; constructor( @@ -65,7 +65,7 @@ export class VariableQueryRunner { ) { this.updateOptionsRequests = new Subject(); this.updateOptionsResults = new Subject(); - this.cancelRequests = new Subject<{ identifier: VariableIdentifier }>(); + this.cancelRequests = new Subject<{ identifier: KeyedVariableIdentifier }>(); this.onNewRequest = this.onNewRequest.bind(this); this.subscription = this.updateOptionsRequests.subscribe(this.onNewRequest); } @@ -74,11 +74,11 @@ export class VariableQueryRunner { this.updateOptionsRequests.next(args); } - getResponse(identifier: VariableIdentifier): Observable { + getResponse(identifier: KeyedVariableIdentifier): Observable { return this.updateOptionsResults.asObservable().pipe(filter((result) => result.identifier === identifier)); } - cancelRequest(identifier: VariableIdentifier): void { + cancelRequest(identifier: KeyedVariableIdentifier): void { this.cancelRequests.next({ identifier }); } @@ -99,11 +99,11 @@ export class VariableQueryRunner { getState, } = this.dependencies; - const beforeUid = getState().templating.transaction.uid; + const beforeKey = getLastKey(getState()); this.updateOptionsResults.next({ identifier, state: LoadingState.Loading }); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); const timeSrv = getTimeSrv(); const runnerArgs = { variable, datasource, searchFilter, timeSrv, runRequest }; const runner = queryRunners.getRunnerForDatasource(datasource); @@ -115,9 +115,9 @@ export class VariableQueryRunner { .pipe( filter(() => { // Lets check if we started another batch during the execution of the observable. If so we just want to abort the rest. - const afterUid = getState().templating.transaction.uid; + const afterKey = getLastKey(getState()); - return beforeUid === afterUid; + return beforeKey === afterKey; }), filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet diff --git a/public/app/features/variables/query/actions.test.tsx b/public/app/features/variables/query/actions.test.tsx index 03623609ebb..7f8afffcd8b 100644 --- a/public/app/features/variables/query/actions.test.tsx +++ b/public/app/features/variables/query/actions.test.tsx @@ -4,9 +4,8 @@ import { DataSourceRef, getDefaultTimeRange, LoadingState } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { QueryVariableModel, VariableHide, VariableQueryEditorProps, VariableRefresh, VariableSort } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, changeVariableProp, @@ -27,6 +26,7 @@ import { updateVariableOptions } from './reducer'; import { addVariableEditorError, changeVariableEditorExtended, + initialVariableEditorState, removeVariableEditorError, setIdInEditor, } from '../editor/reducer'; @@ -40,6 +40,8 @@ import { setVariableQueryRunner, VariableQueryRunner } from './VariableQueryRunn import { setDataSourceSrv } from '@grafana/runtime'; import { variablesInitTransaction } from '../state/transactionReducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const mocks: Record = { datasource: { @@ -93,8 +95,10 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenNoActionsWhereDispatched(); }); @@ -109,16 +113,18 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); @@ -132,21 +138,19 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 2; - - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -159,23 +163,21 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 3; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -188,21 +190,19 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable), 'search'), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), 'search'), true); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions] = actions; - const expectedNumberOfActions = 2; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))) + ); }); }); @@ -216,19 +216,26 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 5; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(variable))); - expect(dispatchedActions[1]).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(dispatchedActions[2]).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(variable)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' }))); + expect(dispatchedActions[2]).toEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); expect(dispatchedActions[3]).toEqual( - variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + toKeyedAction( + 'key', + variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + ) ); expect(dispatchedActions[4].type).toEqual(notifyApp.type); expect(dispatchedActions[4].payload.title).toEqual('Templating [0]'); @@ -253,12 +260,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -276,12 +288,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -298,12 +315,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -319,15 +341,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); @@ -335,7 +362,14 @@ describe('query actions', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ datasource: { uid: 'other' } }); const editor = mocks.VariableQueryEditor; - const preloadedState: any = { templating: { editor: { extended: { dataSource: { type: 'previous' } } } } }; + const previousDataSource: any = { type: 'previous' }; + const templatingState = { + editor: { + ...initialVariableEditorState, + extended: { dataSource: previousDataSource, VariableQueryEditor: editor }, + }, + }; + const preloadedState = getPreloadedState('key', templatingState); mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({ components: { VariableQueryEditor: editor }, @@ -344,17 +378,20 @@ describe('query actions', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })) + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) ) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' })), - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' }))), + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -371,15 +408,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -396,21 +438,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -427,21 +477,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -457,21 +515,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -484,19 +550,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const errorText = 'Query cannot contain a reference to itself. Variable: $' + variable.name; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [editorError] = actions; - const expectedNumberOfActions = 1; - - expect(editorError).toEqual(addVariableEditorError({ errorProp: 'query', errorText })); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'query', errorText })) + ); }); }); @@ -672,6 +739,7 @@ function createVariable(extend?: Partial): QueryVariableMode return { type: 'query', id: '0', + rootStateKey: 'key', global: false, current: createOption(''), options: [], diff --git a/public/app/features/variables/query/actions.ts b/public/app/features/variables/query/actions.ts index 8c9f9c0fb6f..45d42c5fec8 100644 --- a/public/app/features/variables/query/actions.ts +++ b/public/app/features/variables/query/actions.ts @@ -5,30 +5,32 @@ import { DataSourceRef } from '@grafana/data'; import { updateOptions } from '../state/actions'; import { QueryVariableModel } from '../types'; import { ThunkResult } from '../../../types'; -import { getVariable } from '../state/selectors'; +import { getVariable, getVariablesState } from '../state/selectors'; import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer'; import { changeVariableProp } from '../state/sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { getVariableQueryEditor } from '../editor/getVariableQueryEditor'; import { getVariableQueryRunner } from './VariableQueryRunner'; import { variableQueryObserver } from './variableQueryObserver'; -import { hasOngoingTransaction } from '../utils'; +import { hasOngoingTransaction, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; import { getQueryVariableEditorState } from '../editor/selectors'; export const updateQueryVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, searchFilter?: string ): ThunkResult => { return async (dispatch, getState) => { try { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); - if (getState().templating.editor.id === variableInState.id) { - dispatch(removeVariableEditorError({ errorProp: 'update' })); + const variableInState = getVariable(identifier, getState()); + if (getVariablesState(rootStateKey, getState()).editor.id === variableInState.id) { + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'update' }))); } const datasource = await getDataSourceSrv().get(variableInState.datasource ?? ''); @@ -44,8 +46,11 @@ export const updateQueryVariableOptions = ( }); } catch (err) { const error = toDataQueryError(err); - if (getState().templating.editor.id === identifier.id) { - dispatch(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + const { rootStateKey } = identifier; + if (getVariablesState(rootStateKey, getState()).editor.id === identifier.id) { + dispatch( + toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); } throw error; @@ -54,33 +59,43 @@ export const updateQueryVariableOptions = ( }; export const initQueryVariableEditor = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); - await dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource)); + const variable = getVariable(identifier, getState()); + await dispatch(changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), variable.datasource)); }; export const changeQueryVariableDataSource = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, name: DataSourceRef | null ): ThunkResult => { return async (dispatch, getState) => { try { - const extendedEditorState = getQueryVariableEditorState(getState().templating.editor); + const { rootStateKey } = identifier; + const { editor } = getVariablesState(rootStateKey, getState()); + const extendedEditorState = getQueryVariableEditorState(editor); const previousDatasource = extendedEditorState?.dataSource; const dataSource = await getDataSourceSrv().get(name ?? ''); if (previousDatasource && previousDatasource.type !== dataSource?.type) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' })) + ) + ); } const VariableQueryEditor = await getVariableQueryEditor(dataSource); dispatch( - changeVariableEditorExtended({ - dataSource: dataSource, - VariableQueryEditor: VariableQueryEditor, - }) + toKeyedAction( + rootStateKey, + changeVariableEditorExtended({ + dataSource, + VariableQueryEditor, + }) + ) ); } catch (err) { console.error(err); @@ -89,22 +104,38 @@ export const changeQueryVariableDataSource = ( }; export const changeQueryVariableQuery = - (identifier: VariableIdentifier, query: any, definition?: string): ThunkResult => + (identifier: KeyedVariableIdentifier, query: any, definition?: string): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); if (hasSelfReferencingQuery(variableInState.name, query)) { const errorText = 'Query cannot contain a reference to itself. Variable: $' + variableInState.name; - dispatch(addVariableEditorError({ errorProp: 'query', errorText })); + dispatch(toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'query', errorText }))); return; } - dispatch(removeVariableEditorError({ errorProp: 'query' })); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'query' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); if (definition) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition })) + ) + ); } else if (typeof query === 'string') { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query })) + ) + ); } await dispatch(updateOptions(identifier)); diff --git a/public/app/features/variables/query/adapter.ts b/public/app/features/variables/query/adapter.ts index 7edd533e685..06a2442ab69 100644 --- a/public/app/features/variables/query/adapter.ts +++ b/public/app/features/variables/query/adapter.ts @@ -7,8 +7,7 @@ import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { QueryVariableEditor } from './QueryVariableEditor'; import { updateQueryVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -25,16 +24,16 @@ export const createQueryVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable, searchFilter) => { - await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter)); + await dispatch(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), searchFilter)); }, getSaveModel: (variable) => { - const { index, id, state, global, queryValue, ...rest } = cloneDeep(variable); + const { index, id, state, global, queryValue, rootStateKey, ...rest } = cloneDeep(variable); // remove options if (variable.refresh !== VariableRefresh.never) { return { ...rest, options: [] }; diff --git a/public/app/features/variables/query/operators.test.ts b/public/app/features/variables/query/operators.test.ts index fa7eba38e49..ad3cdcce819 100644 --- a/public/app/features/variables/query/operators.test.ts +++ b/public/app/features/variables/query/operators.test.ts @@ -3,6 +3,7 @@ import { queryBuilder } from '../shared/testing/builders'; import { FieldType, toDataFrame } from '@grafana/data'; import { updateVariableOptions } from './reducer'; import { areMetricFindValues, toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; describe('operators', () => { beforeEach(() => { @@ -12,7 +13,7 @@ describe('operators', () => { describe('validateVariableSelection', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn().mockResolvedValue({}); const observable = of(undefined).pipe(validateVariableSelection({ variable, dispatch })); @@ -27,7 +28,7 @@ describe('operators', () => { describe('updateOptionsState', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn(); const getTemplatedRegexFunc = jest.fn().mockReturnValue('getTemplatedRegexFunc result'); @@ -39,11 +40,14 @@ describe('operators', () => { expect(getTemplatedRegexFunc).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, - }) + toKeyedAction( + 'key', + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, + }) + ) ); }); }); diff --git a/public/app/features/variables/query/operators.ts b/public/app/features/variables/query/operators.ts index 3f57960c529..2085df4bd23 100644 --- a/public/app/features/variables/query/operators.ts +++ b/public/app/features/variables/query/operators.ts @@ -3,12 +3,12 @@ import { map, mergeMap } from 'rxjs/operators'; import { QueryVariableModel } from '../types'; import { ThunkDispatch } from '../../../types'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { validateVariableSelectionState } from '../state/actions'; import { FieldType, getFieldDisplayName, isDataFrame, MetricFindValue, PanelData } from '@grafana/data'; import { updateVariableOptions } from './reducer'; -import { getTemplatedRegex } from '../utils'; +import { getTemplatedRegex, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; import { getProcessedDataFrames } from 'app/features/query/state/runRequest'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; export function toMetricFindValues(): OperatorFunction { return (source) => @@ -102,9 +102,13 @@ export function updateOptionsState(args: { source.pipe( map((results) => { const { variable, dispatch, getTemplatedRegexFunc } = args; + if (!variable.rootStateKey) { + console.error('updateOptionsState: variable.rootStateKey is not defined'); + return; + } const templatedRegex = getTemplatedRegexFunc(variable); const payload = toVariablePayload(variable, { results, templatedRegex }); - dispatch(updateVariableOptions(payload)); + dispatch(toKeyedAction(variable.rootStateKey, updateVariableOptions(payload))); }) ); } @@ -124,7 +128,7 @@ export function validateVariableSelection(args: { // So after search and selection the current value is already update so no setValue, refresh and URL update is performed // The if statement below fixes https://github.com/grafana/grafana/issues/25671 if (!searchFilter) { - return from(dispatch(validateVariableSelectionState(toVariableIdentifier(variable)))); + return from(dispatch(validateVariableSelectionState(toKeyedVariableIdentifier(variable)))); } return of(); diff --git a/public/app/features/variables/query/reducer.test.ts b/public/app/features/variables/query/reducer.test.ts index dd143490b2c..106f8c50903 100644 --- a/public/app/features/variables/query/reducer.test.ts +++ b/public/app/features/variables/query/reducer.test.ts @@ -1,3 +1,6 @@ +import { cloneDeep } from 'lodash'; +import { MetricFindValue } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { metricNamesToVariableValues, @@ -6,11 +9,10 @@ import { updateVariableOptions, } from './reducer'; import { QueryVariableModel, VariableSort } from '../types'; -import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createQueryVariableAdapter } from './adapter'; -import { MetricFindValue } from '@grafana/data'; +import { toVariablePayload } from '../utils'; describe('queryVariableReducer', () => { const adapter = createQueryVariableAdapter(); diff --git a/public/app/features/variables/query/reducer.ts b/public/app/features/variables/query/reducer.ts index 03e2e0d89e8..e4afc5c609d 100644 --- a/public/app/features/variables/query/reducer.ts +++ b/public/app/features/variables/query/reducer.ts @@ -4,8 +4,9 @@ import { MetricFindValue, stringToJsRegex } from '@grafana/data'; import { initialVariableModelState, QueryVariableModel, VariableOption, VariableRefresh, VariableSort } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NONE_VARIABLE_TEXT, NONE_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; interface VariableOptionsUpdate { templatedRegex: string; diff --git a/public/app/features/variables/query/variableQueryObserver.test.ts b/public/app/features/variables/query/variableQueryObserver.test.ts index 7b2e41fc8c8..8232e74df55 100644 --- a/public/app/features/variables/query/variableQueryObserver.test.ts +++ b/public/app/features/variables/query/variableQueryObserver.test.ts @@ -1,6 +1,6 @@ import { variableQueryObserver } from './variableQueryObserver'; import { LoadingState } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UpdateOptionsResults } from './VariableQueryRunner'; function getTestContext(args: { next?: UpdateOptionsResults; error?: any; complete?: boolean }) { @@ -27,7 +27,7 @@ function getTestContext(args: { next?: UpdateOptionsResults; error?: any; comple return { resolve, reject, subscription, observer }; } -const identifier: VariableIdentifier = { id: 'id', type: 'query' }; +const identifier: KeyedVariableIdentifier = { id: 'id', type: 'query', rootStateKey: 'uid' }; describe('variableQueryObserver', () => { describe('when receiving a Done state', () => { diff --git a/public/app/features/variables/shared/testing/variableBuilder.ts b/public/app/features/variables/shared/testing/variableBuilder.ts index a1bae77fc59..f966605787b 100644 --- a/public/app/features/variables/shared/testing/variableBuilder.ts +++ b/public/app/features/variables/shared/testing/variableBuilder.ts @@ -19,6 +19,11 @@ export class VariableBuilder { return this; } + withRootStateKey(key: string) { + this.variable.rootStateKey = key; + return this; + } + build(): T { return this.variable; } diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 1b90b4d77ce..81b9d40b130 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,13 +1,12 @@ import { AnyAction } from 'redux'; -import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; +import { getPreloadedState, getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; 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 { cancelVariables, changeVariableMultiValue, @@ -27,7 +26,6 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { constantBuilder, customBuilder, @@ -52,6 +50,8 @@ import * as runtime from '@grafana/runtime'; import { LoadingState } from '@grafana/data'; import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -81,44 +81,66 @@ runtime.setDataSourceSrv({ describe('shared actions', () => { describe('when initDashboardTemplating is dispatched', () => { it('then correct actions are dispatched', () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; + const dashboard: any = { templating: { list } }; - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(initDashboardTemplating(list)) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) .thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(8); expect(dispatchedActions[0]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 0, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 0, model: query }))) ); expect(dispatchedActions[1]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) ); expect(dispatchedActions[2]).toEqual( - addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom })) + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom }))) ); expect(dispatchedActions[3]).toEqual( - addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox }))) ); // because uuid are dynamic we need to get the uuid from the resulting state // an alternative would be to add our own uuids in the model above instead expect(dispatchedActions[4]).toEqual( - variableStateNotStarted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[5]).toEqual( - variableStateNotStarted(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...constant, id: dispatchedActions[5].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[6]).toEqual( - variableStateNotStarted(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...custom, id: dispatchedActions[6].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[7]).toEqual( - variableStateNotStarted(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.action.payload.id }) + ) + ) ); return true; @@ -128,52 +150,71 @@ describe('shared actions', () => { describe('when processVariables is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const dashboard: any = { templating: { list } }; + const preloadedState = getPreloadedState(key, {}); const locationService: any = { getSearchObject: () => ({}) }; runtime.setLocationService(locationService); const variableQueryRunner: any = { cancelRequest: jest.fn(), queueRequest: jest.fn(), - getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toVariableIdentifier(query) }), + getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toKeyedVariableIdentifier(query) }), destroy: jest.fn(), }; setVariableQueryRunner(variableQueryRunner); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(5); expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.id })) + toKeyedAction( + key, + variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.action.payload.id })) + ) ); expect(dispatchedActions[1]).toEqual( - variableStateCompleted(toVariablePayload({ ...constant, id: dispatchedActions[1].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...constant, id: dispatchedActions[1].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[2]).toEqual( - variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.action.payload.id })) + ) ); expect(dispatchedActions[3]).toEqual( - variableStateCompleted(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[4]).toEqual( - variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id })) + ) ); return true; @@ -183,8 +224,10 @@ describe('shared actions', () => { // Fix for https://github.com/grafana/grafana/issues/28791 it('fix for https://github.com/grafana/grafana/issues/28791', async () => { setVariableQueryRunner(new VariableQueryRunner()); + const key = 'key'; const stats = queryBuilder() .withId('stats') + .withRootStateKey(key) .withName('stats') .withQuery('stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -195,6 +238,7 @@ describe('shared actions', () => { const substats = queryBuilder() .withId('substats') + .withRootStateKey(key) .withName('substats') .withQuery('stats.$stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -204,45 +248,64 @@ describe('shared actions', () => { .build(); const list = [stats, substats]; + const dashboard: any = { templating: { list } }; const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT }; const locationService: any = { getSearchObject: () => query }; runtime.setLocationService(locationService); - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const preloadedState = getPreloadedState(key, {}); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload(stats)), - updateVariableOptions( - toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(stats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { + option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false }, + }) + ) ), - variableStateCompleted(toVariablePayload(stats)), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(stats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + ) ), - variableStateFetching(toVariablePayload(substats)), - updateVariableOptions( - toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(substats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, - }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, + }) + ) ), - variableStateCompleted(toVariablePayload(substats)), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, - }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(substats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, + }) + ) ) ); }); @@ -260,36 +323,42 @@ describe('shared actions', () => { ${undefined} | ${'B'} | ${undefined} | ${'should not dispatch setCurrentVariableValue'} `('then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder().withId('0').withRootStateKey(key).withCurrent(withCurrent).withoutOptions().build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expected, value: expected, selected: false } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -309,37 +378,49 @@ describe('shared actions', () => { 'then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expectedText, expectedSelected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withMulti().withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti() + .withCurrent(withCurrent) + .withoutOptions() + .build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti() .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -351,154 +432,196 @@ describe('shared actions', () => { describe('changeVariableName', () => { describe('when changeVariableName is dispatched with the same name', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), constant.name), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), constant.name), true) .thenDispatchedActionsShouldEqual( - changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + ) ); }); }); 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(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, 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(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId(NEW_VARIABLE_ID).withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } })) ); }); }); 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(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '__newName'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '__newName'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '__newName', - errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables", - }) + toKeyedAction( + key, + 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(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '#constant!'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '#constant!'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '#constant!', - errorText: 'Only word and digit characters are allowed in variable names', - }) + toKeyedAction( + key, + 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(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'textbox'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'textbox'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: 'textbox', - errorText: 'Variable with the same name already exists', - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: 'textbox', + errorText: 'Variable with the same name already exists', + }) + ) ); }); }); @@ -507,28 +630,42 @@ describe('shared actions', () => { describe('changeVariableMultiValue', () => { describe('when changeVariableMultiValue is dispatched for variable with multi enabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(true).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(true) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), false), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), false), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: false, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: false, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: 'A', - text: 'A', - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: 'A', + text: 'A', + selected: true, + }, + }) + ) ) ); }); @@ -536,28 +673,42 @@ describe('shared actions', () => { describe('when changeVariableMultiValue is dispatched for variable with multi disabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(false).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(false) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), true), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), true), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: true, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: true, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: ['A'], - text: ['A'], - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: ['A'], + text: ['A'], + selected: true, + }, + }) + ) ) ); }); @@ -567,14 +718,15 @@ describe('shared actions', () => { describe('cleanUpVariables', () => { describe('when called', () => { it('then correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cleanUpVariables()) + .whenActionIsDispatched(cleanUpVariables(key)) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); }); }); @@ -588,14 +740,15 @@ describe('shared actions', () => { describe('when called', () => { it('then cancelAllInFlightRequests should be called and correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cancelVariables({ getBackendSrv: () => backendSrvMock })) + .whenActionIsDispatched(cancelVariables(key, { getBackendSrv: () => backendSrvMock })) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); expect(cancelAllInFlightRequestsMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 902fbba742e..b1d57723a8b 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -28,7 +28,7 @@ import { VariableWithOptions, } from '../types'; import { AppNotification, StoreState, ThunkResult } from '../../../types'; -import { getVariable, getVariables } from './selectors'; +import { getIfExistsLastKey, getVariable, getVariablesByKey, getVariablesState } from './selectors'; import { variableAdapters } from '../adapters'; import { Graph } from '../../../core/utils/dag'; import { notifyApp } from 'app/core/actions'; @@ -41,7 +41,7 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types'; +import { KeyedVariableIdentifier } from './types'; import { contextSrv } from 'app/core/services/context_srv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; import { alignCurrentWithMulti } from '../shared/multiOptions'; @@ -71,6 +71,9 @@ import { getCurrentText, getVariableRefresh, hasOngoingTransaction, + toKeyedVariableIdentifier, + toStateKey, + toVariablePayload, } from '../utils'; import { store } from 'app/store/store'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; @@ -80,6 +83,7 @@ import { locationService } from '@grafana/runtime'; import { appEvents } from '../../../core/core'; import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; // process flow queryVariable // thunk => processVariables @@ -111,24 +115,27 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; // thunk => variableUpdated // adapter => updateOptions for dependent nodes -export const initDashboardTemplating = (list: VariableModel[]): ThunkResult => { +export const initDashboardTemplating = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch, getState) => { let orderIndex = 0; + const list = dashboard.templating.list; for (let index = 0; index < list.length; index++) { const model = fixSelectedInconsistency(list[index]); + model.rootStateKey = key; if (!variableAdapters.getIfExists(model.type)) { continue; } - dispatch(addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))); + dispatch( + toKeyedAction(key, addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))) + ); } getTemplateSrv().updateTimeRange(getTimeSrv().timeRange()); - const variables = getVariables(getState()); - for (let index = 0; index < variables.length; index++) { - const variable = variables[index]; - dispatch(variableStateNotStarted(toVariablePayload(variable))); + const variables = getVariablesByKey(key, getState()); + for (const variable of variables) { + dispatch(toKeyedAction(key, variableStateNotStarted(toVariablePayload(variable)))); } }; }; @@ -159,7 +166,7 @@ export function fixSelectedInconsistency(model: VariableModel): VariableModel | return model; } -export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult => { +export const addSystemTemplateVariables = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch) => { const dashboardModel: DashboardVariableModel = { ...initialVariableModelState, @@ -179,12 +186,15 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable( - toVariablePayload(dashboardModel, { - global: dashboardModel.global, - index: dashboardModel.index, - model: dashboardModel, - }) + toKeyedAction( + key, + addVariable( + toVariablePayload(dashboardModel, { + global: dashboardModel.global, + index: dashboardModel.index, + model: dashboardModel, + }) + ) ) ); @@ -206,7 +216,10 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + toKeyedAction( + key, + addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + ) ); const userModel: UserVariableModel = { @@ -228,25 +241,39 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel })) + toKeyedAction( + key, + addVariable( + toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel }) + ) + ) ); }; }; -export const changeVariableMultiValue = (identifier: VariableIdentifier, multi: boolean): ThunkResult => { +export const changeVariableMultiValue = (identifier: KeyedVariableIdentifier, multi: boolean): ThunkResult => { return (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const { rootStateKey: key } = identifier; + const variable = getVariable(identifier, getState()); const current = alignCurrentWithMulti(variable.current, multi); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))) + ); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))) + ); }; }; export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + const dependencies: VariableModel[] = []; - for (const otherVariable of getVariables(state)) { + for (const otherVariable of getVariablesByKey(variable.rootStateKey, state)) { if (variable === otherVariable) { continue; } @@ -258,13 +285,17 @@ export const processVariableDependencies = async (variable: VariableModel, state } } - if (!isWaitingForDependencies(dependencies, state)) { + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, state)) { return; } await new Promise((resolve) => { const unsubscribe = store.subscribe(() => { - if (!isWaitingForDependencies(dependencies, store.getState())) { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, store.getState())) { unsubscribe(); resolve(); } @@ -272,12 +303,12 @@ export const processVariableDependencies = async (variable: VariableModel, state }); }; -const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreState): boolean => { +const isWaitingForDependencies = (key: string, dependencies: VariableModel[], state: StoreState): boolean => { if (dependencies.length === 0) { return false; } - const variables = getVariables(state); + const variables = getVariablesByKey(key, state); const notCompletedDependencies = dependencies.filter((d) => variables.some((v) => v.id === d.id && (v.state === LoadingState.NotStarted || v.state === LoadingState.Loading)) ); @@ -286,11 +317,11 @@ const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreSta }; export const processVariable = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, queryParams: UrlQueryMap ): ThunkResult> => { return async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); await processVariableDependencies(variable, getState()); const urlValue = queryParams['var-' + variable.name]; @@ -306,7 +337,7 @@ export const processVariable = ( refreshableVariable.refresh === VariableRefresh.onDashboardLoad || refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged ) { - await dispatch(updateOptions(toVariableIdentifier(refreshableVariable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(refreshableVariable))); return; } } @@ -316,11 +347,12 @@ export const processVariable = ( }; }; -export const processVariables = (): ThunkResult> => { +export const processVariables = (key: string): ThunkResult> => { return async (dispatch, getState) => { const queryParams = locationService.getSearchObject(); - const promises = getVariables(getState()).map( - async (variable: VariableModel) => await dispatch(processVariable(toVariableIdentifier(variable), queryParams)) + const promises = getVariablesByKey(key, getState()).map( + async (variable: VariableModel) => + await dispatch(processVariable(toKeyedVariableIdentifier(variable), queryParams)) ); await Promise.all(promises); @@ -328,19 +360,19 @@ export const processVariables = (): ThunkResult> => { }; export const setOptionFromUrl = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue ): ThunkResult> => { return async (dispatch, getState) => { const stringUrlValue = ensureStringValues(urlValue); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (getVariableRefresh(variable) !== VariableRefresh.never) { // updates options - await dispatch(updateOptions(toVariableIdentifier(variable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(variable))); } // get variable from state - const variableFromState = getVariable(variable.id, getState()); + const variableFromState = getVariable(toKeyedVariableIdentifier(variable), getState()); if (!variableFromState) { throw new Error(`Couldn't find variable with name: ${variable.name}`); } @@ -418,11 +450,11 @@ export const selectOptionsForCurrentValue = (variable: VariableWithOptions): Var }; export const validateVariableSelectionState = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, defaultValue?: string ): ThunkResult> => { return (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); const current = variableInState.current || ({} as unknown as VariableOption); const setValue = variableAdapters.get(variableInState.type).setValue; @@ -474,12 +506,13 @@ export const validateVariableSelectionState = ( }; export const setOptionAsCurrent = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, current: VariableOption, emitChanges: boolean ): ThunkResult> => { return async (dispatch) => { - dispatch(setCurrentVariableValue(toVariablePayload(identifier, { option: current }))); + const { rootStateKey: key } = identifier; + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(identifier, { option: current })))); return await dispatch(variableUpdated(identifier, emitChanges)); }; }; @@ -507,25 +540,26 @@ const createGraph = (variables: VariableModel[]) => { }; export const variableUpdated = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, emitChangeEvents: boolean, events: typeof appEvents = appEvents ): ThunkResult> => { return async (dispatch, getState) => { const state = getState(); - const variableInState = getVariable(identifier.id, state); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, state); // if we're initializing variables ignore cascading update because we are in a boot up scenario - if (state.templating.transaction.status === TransactionStatus.Fetching) { + if (getVariablesState(rootStateKey, state).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))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); dispatch(completeVariableLoading(identifier)); } return Promise.resolve(); } - const variables = getVariables(state); + const variables = getVariablesByKey(rootStateKey, state); const g = createGraph(variables); const panels = state.dashboard?.getModel()?.panels ?? []; const event: VariablesChangedEvent = isAdHoc(variableInState) @@ -541,14 +575,14 @@ export const variableUpdated = ( return Promise.resolve(); } - return dispatch(updateOptions(toVariableIdentifier(variable))); + return dispatch(updateOptions(toKeyedVariableIdentifier(variable))); }); } return Promise.all(promises).then(() => { if (emitChangeEvents) { events.publish(new VariablesChanged(event)); - locationService.partial(getQueryWithVariables(getState)); + locationService.partial(getQueryWithVariables(rootStateKey, getState)); } }); }; @@ -561,12 +595,13 @@ export interface OnTimeRangeUpdatedDependencies { export const onTimeRangeUpdated = ( + key: string, timeRange: TimeRange, dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), events: appEvents } ): ThunkResult> => async (dispatch, getState) => { dependencies.templateSrv.updateTimeRange(timeRange); - const variablesThatNeedRefresh = getVariables(getState()).filter((variable) => { + const variablesThatNeedRefresh = getVariablesByKey(key, getState()).filter((variable) => { if (variable.hasOwnProperty('refresh') && variable.hasOwnProperty('options')) { const variableWithRefresh = variable as unknown as QueryVariableModel; return variableWithRefresh.refresh === VariableRefresh.onTimeRangeChanged; @@ -577,7 +612,7 @@ export const onTimeRangeUpdated = const variableIds = variablesThatNeedRefresh.map((variable) => variable.id); const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) => - dispatch(timeRangeUpdated(toVariableIdentifier(variable))) + dispatch(timeRangeUpdated(toKeyedVariableIdentifier(variable))) ); try { @@ -590,14 +625,14 @@ export const onTimeRangeUpdated = }; const timeRangeUpdated = - (identifier: VariableIdentifier): ThunkResult> => + (identifier: KeyedVariableIdentifier): ThunkResult> => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id); + const variableInState = getVariable(identifier, getState()); const previousOptions = variableInState.options.slice(); - await dispatch(updateOptions(toVariableIdentifier(variableInState), true)); + await dispatch(updateOptions(toKeyedVariableIdentifier(variableInState), true)); - const updatedVariable = getVariable(identifier.id, getState()); + const updatedVariable = getVariable(identifier, getState()); const updatedOptions = updatedVariable.options; if (JSON.stringify(previousOptions) !== JSON.stringify(updatedOptions)) { @@ -607,11 +642,11 @@ const timeRangeUpdated = }; export const templateVarsChangedInUrl = - (vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => + (key: string, vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => async (dispatch, getState) => { const update: Array> = []; const dashboard = getState().dashboard.getModel(); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { const key = `var-${variable.name}`; if (!vars.hasOwnProperty(key)) { // key not found quick exit @@ -657,7 +692,7 @@ export function isVariableUrlValueDifferentFromCurrent(variable: VariableModel, return !isEqual(variableValue, stringUrlValue); } -const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { +const getQueryWithVariables = (key: string, getState: () => StoreState): UrlQueryMap => { const queryParams = locationService.getSearchObject(); const queryParamsNew = Object.keys(queryParams) @@ -667,7 +702,7 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { return obj; }, {} as UrlQueryMap); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { if (variable.skipUrlSync) { continue; } @@ -680,27 +715,32 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { }; export const initVariablesTransaction = - (dashboardUid: string, dashboard: DashboardModel): ThunkResult => + (urlUid: string, dashboard: DashboardModel): ThunkResult> => async (dispatch, getState) => { try { - const transactionState = getState().templating.transaction; - if (transactionState.status === TransactionStatus.Fetching) { - // previous dashboard is still fetching variables, cancel all requests - dispatch(cancelVariables()); + const uid = toStateKey(urlUid); + const state = getState(); + const lastKey = getIfExistsLastKey(state); + if (lastKey) { + const transactionState = getVariablesState(lastKey, state).transaction; + if (transactionState.status === TransactionStatus.Fetching) { + // previous dashboard is still fetching variables, cancel all requests + dispatch(cancelVariables(lastKey)); + } } // Start init transaction - dispatch(variablesInitTransaction({ uid: dashboardUid })); + dispatch(toKeyedAction(uid, variablesInitTransaction({ uid }))); // Add system variables like __dashboard and __user - dispatch(addSystemTemplateVariables(dashboard)); + dispatch(addSystemTemplateVariables(uid, dashboard)); // Load all variables into redux store - dispatch(initDashboardTemplating(dashboard.templating.list)); + dispatch(initDashboardTemplating(uid, dashboard)); // Migrate data source name to ref - dispatch(migrateVariablesDatasourceNameToRef()); + dispatch(migrateVariablesDatasourceNameToRef(uid)); // Process all variable updates - await dispatch(processVariables()); - // Mark update as complete - dispatch(variablesCompleteTransaction({ uid: dashboardUid })); + await dispatch(processVariables(uid)); + // Set transaction as complete + dispatch(toKeyedAction(uid, variablesCompleteTransaction({ uid }))); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Templating init failed', err))); console.error(err); @@ -708,10 +748,11 @@ export const initVariablesTransaction = }; export function migrateVariablesDatasourceNameToRef( - getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv + key: string, + getDatasourceSrvFunc = getDatasourceSrv ): ThunkResult { - return function (dispatch, getState) { - const variables = getVariables(getState()); + return (dispatch, getState) => { + const variables = getVariablesByKey(key, getState()); for (const variable of variables) { if (!isAdHoc(variable) && !isQuery(variable)) { continue; @@ -726,43 +767,51 @@ export function migrateVariablesDatasourceNameToRef( // the call to getInstanceSettings needs to be done after initDashboardTemplating because we might have // datasource variables that need to be resolved const ds = getDatasourceSrvFunc().getInstanceSettings(nameOrRef); - const dsRef = !ds ? { uid: nameOrRef } : getDataSourceRef(ds); - dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef }))); + const dsRef = ds ? getDataSourceRef(ds) : { uid: nameOrRef }; + dispatch( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef })) + ) + ); } }; } -export const cleanUpVariables = (): ThunkResult => (dispatch) => { - dispatch(cleanVariables()); - dispatch(cleanEditorState()); - dispatch(cleanPickerState()); - dispatch(variablesClearTransaction()); -}; +export const cleanUpVariables = + (key: string): ThunkResult => + (dispatch) => { + dispatch(toKeyedAction(key, cleanVariables())); + dispatch(toKeyedAction(key, cleanEditorState())); + dispatch(toKeyedAction(key, cleanPickerState())); + dispatch(toKeyedAction(key, variablesClearTransaction())); + }; type CancelVariablesDependencies = { getBackendSrv: typeof getBackendSrv }; export const cancelVariables = - (dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => + (key: string, dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => (dispatch) => { dependencies.getBackendSrv().cancelAllInFlightRequests(); - dispatch(cleanUpVariables()); + dispatch(cleanUpVariables(key)); }; export const updateOptions = - (identifier: VariableIdentifier, rethrow = false): ThunkResult> => + (identifier: KeyedVariableIdentifier, rethrow = false): ThunkResult> => async (dispatch, getState) => { + const { rootStateKey } = identifier; try { - if (!hasOngoingTransaction(getState())) { + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); - dispatch(variableStateFetching(toVariablePayload(variableInState))); - await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState))); + const variableInState = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, variableStateFetching(toVariablePayload(variableInState)))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); await variableAdapters.get(variableInState.type).updateOptions(variableInState); dispatch(completeVariableLoading(identifier)); } catch (error) { - dispatch(variableStateFailed(toVariablePayload(identifier, { error }))); + dispatch(toKeyedAction(rootStateKey, variableStateFailed(toVariablePayload(identifier, { error })))); if (!rethrow) { console.error(error); @@ -778,7 +827,7 @@ export const updateOptions = export const createVariableErrorNotification = ( message: string, error: any, - identifier?: VariableIdentifier + identifier?: KeyedVariableIdentifier ): AppNotification => createErrorNotification( `${identifier ? `Templating [${identifier.id}]` : 'Templating'}`, @@ -786,31 +835,33 @@ export const createVariableErrorNotification = ( ); export const completeVariableLoading = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch, getState) => { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.state !== LoadingState.Done) { - dispatch(variableStateCompleted(toVariablePayload(variableInState))); + dispatch(toKeyedAction(identifier.rootStateKey, variableStateCompleted(toVariablePayload(variableInState)))); } }; export function upgradeLegacyQueries( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv ): ThunkResult { return async function (dispatch, getState) { - if (!hasOngoingTransaction(getState())) { + const { id, rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (!isQuery(variable)) { return; @@ -832,11 +883,16 @@ export function upgradeLegacyQueries( } const query = { - refId: `${datasource.name}-${identifier.id}-Variable-Query`, + refId: `${datasource.name}-${id}-Variable-Query`, query: variable.query, }; - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Failed to upgrade legacy queries', err))); console.error(err); diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index be9c57493c3..5ea13a64fd9 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -13,9 +13,10 @@ import { import { VariableAdapter } from '../adapters'; import { dashboardReducer } from 'app/features/dashboard/state/reducers'; -import { templatingReducers, TemplatingState } from './reducers'; -import { DashboardState } from '../../../types'; +import { DashboardState, StoreState } from '../../../types'; import { NEW_VARIABLE_ID } from '../constants'; +import { keyedVariablesReducer, KeyedVariablesState } from './keyedVariablesReducer'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; export const getVariableState = ( noOfVariables: number, @@ -86,6 +87,7 @@ export const getVariableState = ( for (let index = 0; index < noOfVariables; index++) { variables[index] = { id: index.toString(), + rootStateKey: 'key', type: 'query', name: `Name-${index}`, hide: VariableHide.dontHide, @@ -102,6 +104,7 @@ export const getVariableState = ( if (includeEmpty) { variables[NEW_VARIABLE_ID] = { id: NEW_VARIABLE_ID, + rootStateKey: 'key', type: 'query', name: `Name-${NEW_VARIABLE_ID}`, hide: VariableHide.dontHide, @@ -122,12 +125,16 @@ export const getVariableTestContext = ( adapter: VariableAdapter, variableOverrides: Partial = {} ) => { - const defaultVariable = { - ...adapter.initialState, + const defaults: Partial = { id: '0', + rootStateKey: 'key', index: 0, name: '0', }; + const defaultVariable = { + ...adapter.initialState, + ...defaults, + }; const initialState: VariablesState = { '0': { ...defaultVariable, ...variableOverrides }, @@ -139,14 +146,31 @@ export const getVariableTestContext = ( export const getRootReducer = () => combineReducers({ dashboard: dashboardReducer, - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type RootReducerType = { dashboard: DashboardState; templating: TemplatingState }; +export type RootReducerType = { dashboard: DashboardState; templating: KeyedVariablesState }; export const getTemplatingRootReducer = () => combineReducers({ - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type TemplatingReducerType = { templating: TemplatingState }; +export type TemplatingReducerType = { templating: KeyedVariablesState }; + +export function getPreloadedState( + key: string, + templatingState: Partial +): Pick { + return { + templating: { + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + ...templatingState, + }, + }, + }, + }; +} diff --git a/public/app/features/variables/state/initVariableTransaction.test.ts b/public/app/features/variables/state/initVariableTransaction.test.ts index 5cbede1bfa3..592d46cc68a 100644 --- a/public/app/features/variables/state/initVariableTransaction.test.ts +++ b/public/app/features/variables/state/initVariableTransaction.test.ts @@ -1,4 +1,4 @@ -import { getRootReducer, RootReducerType } from './helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; @@ -11,15 +11,15 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariablePayload } from './types'; import { adHocBuilder, constantBuilder, datasourceBuilder, queryBuilder } from '../shared/testing/builders'; -import { cleanEditorState, initialVariableEditorState } from '../editor/reducer'; +import { cleanEditorState } from '../editor/reducer'; import { + initialTransactionState, variablesClearTransaction, variablesCompleteTransaction, variablesInitTransaction, } from './transactionReducer'; -import { cleanPickerState, initialState } from '../pickers/OptionsPicker/reducer'; +import { cleanPickerState } from '../pickers/OptionsPicker/reducer'; import { cleanVariables } from './variablesReducer'; import { createAdHocVariableAdapter } from '../adhoc/adapter'; import { createDataSourceVariableAdapter } from '../datasource/adapter'; @@ -30,6 +30,8 @@ import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelp import { setVariableQueryRunner } from '../query/VariableQueryRunner'; import { createDataSourceOptions } from '../datasource/reducer'; import { initVariablesTransaction } from './actions'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -39,7 +41,7 @@ variableAdapters.setInit(() => [ ]); function getTestContext(variables?: VariableModel[]) { - const uid = 'uid'; + const key = 'key'; const constant = constantBuilder().withId('constant').withName('constant').build(); const templating = { list: variables ?? [constant] }; const getInstanceSettingsMock = jest.fn().mockReturnValue(undefined); @@ -57,34 +59,34 @@ function getTestContext(variables?: VariableModel[]) { }; setVariableQueryRunner(variableQueryRunner); - const dashboard: any = { title: 'Some dash', uid, templating }; + const dashboard: any = { title: 'Some dash', uid: key, templating }; - return { constant, getInstanceSettingsMock, templating, uid, dashboard }; + return { constant, getInstanceSettingsMock, templating, key, dashboard }; } describe('initVariablesTransaction', () => { describe('when called and the previous dashboard has completed', () => { it('then correct actions are dispatched', async () => { - const { constant, uid, dashboard } = getTestContext(); + const { constant, key, dashboard } = getTestContext(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[1].type).toEqual(addVariable.type); - expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[2].type).toEqual(addVariable.type); - expect(dispatchedActions[2].payload.id).toEqual('__org'); - expect(dispatchedActions[3].type).toEqual(addVariable.type); - expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))) ); - expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant))); + expect(dispatchedActions[5]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[6]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); - expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[7]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 8; }); }); @@ -92,54 +94,73 @@ describe('initVariablesTransaction', () => { describe('and there are variables that have data source that need to be migrated', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const ds = datasourceBuilder().withId('ds').withName('ds').withQuery('prom').build(); - const query = queryBuilder().withId('query').withName('query').withDatasource(legacyDs).build(); - const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(legacyDs).build(); - const { uid, dashboard } = getTestContext([ds, query, adhoc]); + const ds = datasourceBuilder().withId('ds').withRootStateKey('key').withName('ds').withQuery('prom').build(); + const query = queryBuilder() + .withId('query') + .withRootStateKey('key') + .withName('query') + .withDatasource(legacyDs) + .build(); + const adhoc = adHocBuilder() + .withId('adhoc') + .withRootStateKey('key') + .withName('adhoc') + .withDatasource(legacyDs) + .build(); + const { key, dashboard } = getTestContext([ds, query, adhoc]); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[1].type).toEqual(addVariable.type); - expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[2].type).toEqual(addVariable.type); - expect(dispatchedActions[2].payload.id).toEqual('__org'); - expect(dispatchedActions[3].type).toEqual(addVariable.type); - expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds })) + toKeyedAction(key, addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds }))) ); expect(dispatchedActions[5]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 1, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 1, model: query }))) ); expect(dispatchedActions[6]).toEqual( - addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc })) + toKeyedAction(key, addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc }))) ); - expect(dispatchedActions[7]).toEqual(variableStateNotStarted(toVariablePayload(ds))); - expect(dispatchedActions[8]).toEqual(variableStateNotStarted(toVariablePayload(query))); - expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(adhoc))); + expect(dispatchedActions[7]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(ds)))); + expect(dispatchedActions[8]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(query)))); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(adhoc)))); expect(dispatchedActions[10]).toEqual( - changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) - ); - expect(dispatchedActions[11]).toEqual( - changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) - ); - expect(dispatchedActions[12]).toEqual(variableStateFetching(toVariablePayload(ds))); - expect(dispatchedActions[13]).toEqual(variableStateCompleted(toVariablePayload(adhoc))); - expect(dispatchedActions[14]).toEqual( - createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined })) - ); - expect(dispatchedActions[15]).toEqual( - setCurrentVariableValue( - toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) ) ); - expect(dispatchedActions[16]).toEqual(variableStateCompleted(toVariablePayload(ds))); - expect(dispatchedActions[17]).toEqual(variableStateFetching(toVariablePayload(query))); - expect(dispatchedActions[18]).toEqual(variableStateCompleted(toVariablePayload(query))); - expect(dispatchedActions[19]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[11]).toEqual( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) + ); + expect(dispatchedActions[12]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(ds)))); + expect(dispatchedActions[13]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(adhoc)))); + expect(dispatchedActions[14]).toEqual( + toKeyedAction(key, createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined }))) + ); + expect(dispatchedActions[15]).toEqual( + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + ) + ) + ); + expect(dispatchedActions[16]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(ds)))); + expect(dispatchedActions[17]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(query)))); + expect(dispatchedActions[18]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(query)))); + expect(dispatchedActions[19]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 20; }); @@ -149,40 +170,32 @@ describe('initVariablesTransaction', () => { 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 { constant, key, dashboard } = getTestContext(); + const transactionState = { ...initialTransactionState, uid: 'previous-uid', status: TransactionStatus.Fetching }; + const preloadedState = getPreloadedState(key, { transaction: transactionState }); - const tester = await reduxTester({ - preloadedState: { - templating: { - transaction: transactionState, - variables: {}, - optionsPicker: { ...initialState }, - editor: { ...initialVariableEditorState }, - }, - } as unknown as RootReducerType, - }) + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(cleanVariables()); - expect(dispatchedActions[1]).toEqual(cleanEditorState()); - expect(dispatchedActions[2]).toEqual(cleanPickerState()); - expect(dispatchedActions[3]).toEqual(variablesClearTransaction()); - expect(dispatchedActions[4]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[5].type).toEqual(addVariable.type); - expect(dispatchedActions[5].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[6].type).toEqual(addVariable.type); - expect(dispatchedActions[6].payload.id).toEqual('__org'); - expect(dispatchedActions[7].type).toEqual(addVariable.type); - expect(dispatchedActions[7].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, cleanVariables())); + expect(dispatchedActions[1]).toEqual(toKeyedAction(key, cleanEditorState())); + expect(dispatchedActions[2]).toEqual(toKeyedAction(key, cleanPickerState())); + expect(dispatchedActions[3]).toEqual(toKeyedAction(key, variablesClearTransaction())); + expect(dispatchedActions[4]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[5].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[5].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[6].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[6].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[7].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[7].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[8]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))) ); - expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[10]).toEqual(variableStateCompleted(toVariablePayload(constant))); - expect(dispatchedActions[11]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[10]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); + expect(dispatchedActions[11]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 12; }); }); diff --git a/public/app/features/variables/state/keyedVariablesReducer.test.ts b/public/app/features/variables/state/keyedVariablesReducer.test.ts new file mode 100644 index 00000000000..4a4dfa346f5 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.test.ts @@ -0,0 +1,128 @@ +import { + initialKeyedVariablesState, + keyedVariablesReducer, + KeyedVariablesState, + toKeyedAction, +} from './keyedVariablesReducer'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { initialTransactionState, variablesCompleteTransaction, variablesInitTransaction } from './transactionReducer'; +import { TransactionStatus } from '../types'; +import { getInitialTemplatingState } from './reducers'; + +describe('dashboardVariablesReducer', () => { + describe('when an toUidAction is dispatched', () => { + it('then state should be correct', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when an toUidAction with variablesInitTransaction is dispatched', () => { + it('then lastUid property should be correct', () => { + const lastUid = 'lastUid'; + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: lastUid, + keys: { + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Fetching, + }, + }, + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when action other than toUidAction is dispatched', () => { + it('then state should not be affected', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'newUid' })) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/variables/state/keyedVariablesReducer.ts b/public/app/features/variables/state/keyedVariablesReducer.ts new file mode 100644 index 00000000000..c3fd48ed5c6 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.ts @@ -0,0 +1,50 @@ +import { AnyAction } from 'redux'; +import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { getTemplatingReducers, TemplatingState } from './reducers'; +import { variablesInitTransaction } from './transactionReducer'; +import { toStateKey } from '../utils'; + +export interface KeyedVariablesState { + lastKey?: string; + keys: Record; +} + +export const initialKeyedVariablesState: KeyedVariablesState = { keys: {} }; + +export interface KeyedAction { + key: string; + action: PayloadAction; +} + +const keyedAction = createAction('templating/keyedAction'); + +export function toKeyedAction(key: string, action: PayloadAction): PayloadAction { + const keyAsString = toStateKey(key); + return keyedAction({ key: keyAsString, action }); +} + +export function keyedVariablesReducer(state = initialKeyedVariablesState, outerAction: AnyAction): KeyedVariablesState { + if (keyedAction.match(outerAction)) { + const { key, action } = outerAction.payload; + const stringKey = toStateKey(key); + const lastKey = variablesInitTransaction.match(action) ? stringKey : state.lastKey; + const templatingReducers = getTemplatingReducers(); + const prevKeyState = state.keys[stringKey]; + const nextKeyState = templatingReducers(prevKeyState, action); + + return { + ...state, + lastKey, + keys: { + ...state.keys, + [stringKey]: nextKeyState, + }, + }; + } + + return state; +} + +export default { + templating: keyedVariablesReducer, +}; diff --git a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts index f8330b3409c..65cf7a33559 100644 --- a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts +++ b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts @@ -2,13 +2,17 @@ import { migrateVariablesDatasourceNameToRef } from './actions'; import { adHocBuilder, queryBuilder } from '../shared/testing/builders'; import { DataSourceRef } from '@grafana/data/src'; import { changeVariableProp } from './sharedReducer'; -import { toVariablePayload } from './types'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toVariablePayload } from '../utils'; function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: string }) { jest.clearAllMocks(); - const query = queryBuilder().withId('query').withName('query').withDatasource(ds).build(); - const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(ds).build(); - const state = { templating: { variables: [query, adhoc] } }; + const key = 'key'; + const query = queryBuilder().withId('query').withRootStateKey(key).withName('query').withDatasource(ds).build(); + const adhoc = adHocBuilder().withId('adhoc').withRootStateKey(key).withName('adhoc').withDatasource(ds).build(); + const templatingState = { variables: { query, adhoc } }; + const state = getPreloadedState(key, templatingState); const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue(state); const getInstanceSettingsMock = jest.fn().mockReturnValue(dsInstance); @@ -18,7 +22,7 @@ function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: str getInstanceSettings: getInstanceSettingsMock, }); - return { query, adhoc, dispatch, getState, getDatasourceSrvFunc }; + return { key, query, adhoc, dispatch, getState, getDatasourceSrvFunc }; } describe('migrateVariablesDatasourceNameToRef', () => { @@ -26,22 +30,34 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('and data source exists', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, { + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, { uid: 'a random uid', type: 'prometheus', }); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - changeVariableProp( - toVariablePayload(query, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(query, { + propName: 'datasource', + propValue: { uid: 'a random uid', type: 'prometheus' }, + }) + ) ) ); expect(dispatch.mock.calls[1][0]).toEqual( - changeVariableProp( - toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(adhoc, { + propName: 'datasource', + propValue: { uid: 'a random uid', type: 'prometheus' }, + }) + ) ) ); }); @@ -50,16 +66,22 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('and data source does not exist', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); expect(dispatch.mock.calls[1][0]).toEqual( - changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); }); }); @@ -68,9 +90,9 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('when called and variables have dataSourceRef', () => { it('then no actions are dispatched', async () => { const legacyDs = { uid: '${ds}', type: 'prometheus' }; - const { dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + const { dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(0); }); diff --git a/public/app/features/variables/state/onTimeRangeUpdated.test.ts b/public/app/features/variables/state/onTimeRangeUpdated.test.ts index 384f48eb448..388916def33 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -10,8 +10,7 @@ import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableRefresh } from '../types'; import { constantBuilder, intervalBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from './helpers'; -import { toVariableIdentifier, toVariablePayload } from './types'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { setCurrentVariableValue, variableStateCompleted, @@ -22,17 +21,20 @@ import { createIntervalOptions } from '../interval/reducer'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { notifyApp } from '../../../core/reducers/appNotification'; import { expect } from '../../../../test/lib/common'; -import { TemplatingState } from './reducers'; import { appEvents } from '../../../core/core'; import { variablesInitTransaction } from './transactionReducer'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); const getTestContext = (dashboard: DashboardModel) => { jest.clearAllMocks(); + const key = 'key'; const interval = intervalBuilder() .withId('interval-0') + .withRootStateKey(key) .withName('interval-0') .withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d') .withCurrent('1m') @@ -41,6 +43,7 @@ const getTestContext = (dashboard: DashboardModel) => { const constant = constantBuilder() .withId('constant-1') + .withRootStateKey(key) .withName('constant-1') .withOptions('a constant') .withCurrent('a constant') @@ -65,17 +68,19 @@ const getTestContext = (dashboard: DashboardModel) => { getModel: () => dashboard, } as unknown as DashboardState; const adapter = variableAdapters.get('interval'); + const templatingState = { + variables: { + 'interval-0': { ...interval }, + 'constant-1': { ...constant }, + }, + }; const preloadedState = { dashboard: dashboardState, - templating: { - variables: { - 'interval-0': { ...interval }, - 'constant-1': { ...constant }, - }, - } as unknown as TemplatingState, + ...getPreloadedState(key, templatingState), } as unknown as RootReducerType; return { + key, interval, range, dependencies, @@ -91,6 +96,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, preloadedState, range, dependencies, @@ -101,20 +107,23 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies)); tester.thenDispatchedActionsShouldEqual( - variablesInitTransaction({ uid: 'a uid' }), - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variablesInitTransaction({ uid: key })), + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -127,6 +136,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are not changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, interval, preloadedState, range, @@ -138,21 +148,26 @@ describe('when onTimeRangeUpdated is dispatched', () => { const base = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched( + setOptionAsCurrent(toKeyedVariableIdentifier(interval), interval.options[0], false) + ); - const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -166,6 +181,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { silenceConsoleOutput(); it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, adapter, preloadedState, range, @@ -179,16 +195,19 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState, debug: true }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(dispatchedActions[1]).toEqual( - variableStateFailed( - toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + toKeyedAction( + key, + variableStateFailed( + toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + ) ) ); expect(dispatchedActions[2].type).toEqual(notifyApp.type); diff --git a/public/app/features/variables/state/processVariable.test.ts b/public/app/features/variables/state/processVariable.test.ts index 9f0c14ab839..d05cfe193fe 100644 --- a/public/app/features/variables/state/processVariable.test.ts +++ b/public/app/features/variables/state/processVariable.test.ts @@ -1,20 +1,20 @@ import { UrlQueryMap } from '@grafana/data'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { initDashboardTemplating, processVariable } from './actions'; import { setCurrentVariableValue, variableStateCompleted, variableStateFetching } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { VariableRefresh } from '../types'; import { updateVariableOptions } from '../query/reducer'; import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { variablesInitTransaction } from './transactionReducer'; import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner'; import { setDataSourceSrv } from '@grafana/runtime'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('app/features/dashboard/services/TimeSrv', () => ({ getTimeSrv: jest.fn().mockReturnValue({ @@ -68,9 +68,11 @@ describe('processVariable', () => { // custom doesn't depend on any other variable // queryDependsOnCustom depends on custom // queryNoDepends doesn't depend on any other variable - const getAndSetupProcessVariableContext = () => { + const key = 'key'; + const getTestContext = () => { const custom = customBuilder() .withId('custom') + .withRootStateKey(key) .withName('custom') .withQuery('A,B,C') .withOptions('A', 'B', 'C') @@ -79,6 +81,7 @@ describe('processVariable', () => { const queryDependsOnCustom = queryBuilder() .withId('queryDependsOnCustom') + .withRootStateKey(key) .withName('queryDependsOnCustom') .withQuery('$custom.*') .withOptions('AA', 'AB', 'AC') @@ -87,6 +90,7 @@ describe('processVariable', () => { const queryNoDepends = queryBuilder() .withId('queryNoDepends') + .withRootStateKey(key) .withName('queryNoDepends') .withQuery('*') .withOptions('A', 'B', 'C') @@ -94,13 +98,15 @@ describe('processVariable', () => { .build(); const list = [custom, queryDependsOnCustom, queryNoDepends]; + const dashboard: any = { templating: { list } }; setVariableQueryRunner(new VariableQueryRunner()); return { + key, custom, queryDependsOnCustom, queryNoDepends, - list, + dashboard, }; }; @@ -108,33 +114,41 @@ describe('processVariable', () => { describe('when processVariable is dispatched for a custom variable without dependencies', () => { describe('and queryParams does not match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = {}; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(custom))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) + ); }); }); describe('and queryParams does match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = { 'var-custom': 'B' }; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: 'custom' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: 'custom' }, + { option: { text: 'B', value: 'B', selected: false } } + ) + ) ), - variableStateCompleted(toVariablePayload(custom)) + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) ); }); }); @@ -148,15 +162,17 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(queryNoDepends))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(queryNoDepends))) + ); }); }); @@ -165,36 +181,42 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -205,22 +227,25 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -230,40 +255,49 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ) ); @@ -279,21 +313,21 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -303,41 +337,47 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -348,27 +388,30 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -378,45 +421,54 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/reducers.test.ts b/public/app/features/variables/state/reducers.test.ts index faaf1f40c15..3e156e2236a 100644 --- a/public/app/features/variables/state/reducers.test.ts +++ b/public/app/features/variables/state/reducers.test.ts @@ -1,10 +1,12 @@ +import { createAction } from '@reduxjs/toolkit'; +import { VariableType } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { initialVariableModelState, QueryVariableModel } from '../types'; import { VariableAdapter, variableAdapters } from '../adapters'; -import { createAction } from '@reduxjs/toolkit'; import { cleanVariables, variablesReducer } from './variablesReducer'; -import { VariablesState, toVariablePayload, VariablePayload } from './types'; -import { VariableType } from '@grafana/data'; +import { VariablePayload, VariablesState } from './types'; +import { toVariablePayload } from '../utils'; const variableAdapter: VariableAdapter = { id: 'mock' as unknown as VariableType, diff --git a/public/app/features/variables/state/reducers.ts b/public/app/features/variables/state/reducers.ts index 005e0b9f33e..7cc63679118 100644 --- a/public/app/features/variables/state/reducers.ts +++ b/public/app/features/variables/state/reducers.ts @@ -1,20 +1,41 @@ -import { combineReducers } from '@reduxjs/toolkit'; -import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer'; -import { variableEditorReducer } from '../editor/reducer'; +import { CombinedState, combineReducers, Reducer } from 'redux'; +import { initialOptionPickerState, optionsPickerReducer, OptionsPickerState } from '../pickers/OptionsPicker/reducer'; +import { initialVariableEditorState, variableEditorReducer, VariableEditorState } from '../editor/reducer'; import { variablesReducer } from './variablesReducer'; -import { transactionReducer } from './transactionReducer'; -import { variableInspectReducer } from '../inspect/reducer'; +import { initialTransactionState, transactionReducer, TransactionState } from './transactionReducer'; +import { initialVariableInspectState, variableInspectReducer, VariableInspectState } from '../inspect/reducer'; +import { initialVariablesState, VariablesState } from './types'; -export const templatingReducers = combineReducers({ - editor: variableEditorReducer, - variables: variablesReducer, - optionsPicker: optionsPickerReducer, - transaction: transactionReducer, - inspect: variableInspectReducer, -}); +export interface TemplatingState { + editor: VariableEditorState; + variables: VariablesState; + optionsPicker: OptionsPickerState; + transaction: TransactionState; + inspect: VariableInspectState; +} -export type TemplatingState = ReturnType; +let templatingReducers: Reducer>; -export default { - templating: templatingReducers, -}; +export function getTemplatingReducers() { + if (!templatingReducers) { + templatingReducers = combineReducers({ + editor: variableEditorReducer, + variables: variablesReducer, + optionsPicker: optionsPickerReducer, + transaction: transactionReducer, + inspect: variableInspectReducer, + }); + } + + return templatingReducers; +} + +export function getInitialTemplatingState() { + return { + editor: initialVariableEditorState, + variables: initialVariablesState, + optionsPicker: initialOptionPickerState, + transaction: initialTransactionState, + inspect: initialVariableInspectState, + }; +} diff --git a/public/app/features/variables/state/selectors.ts b/public/app/features/variables/state/selectors.ts index eb9de79d4a9..685b0b9b51d 100644 --- a/public/app/features/variables/state/selectors.ts +++ b/public/app/features/variables/state/selectors.ts @@ -2,52 +2,63 @@ import { StoreState } from '../../../types'; import { VariableModel } from '../types'; import { getState } from '../../../store/store'; import memoizeOne from 'memoize-one'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; +import { toStateKey } from '../utils'; +import { KeyedVariableIdentifier, VariablesState } from './types'; export const getVariable = ( - id: string, + identifier: KeyedVariableIdentifier, state: StoreState = getState(), throwWhenMissing = true ): T => { - if (!state.templating.variables[id]) { + const { id, rootStateKey } = identifier; + const variablesState = getVariablesState(rootStateKey, state); + if (!variablesState.variables[id]) { if (throwWhenMissing) { throw new Error(`Couldn't find variable with id:${id}`); } return undefined as unknown as T; } - return state.templating.variables[id] as T; + return variablesState.variables[id] as T; }; -export const getFilteredVariables = (filter: (model: VariableModel) => boolean, state: StoreState = getState()) => { - return Object.values(state.templating.variables) +function getFilteredVariablesByKey( + filter: (model: VariableModel) => boolean, + key: string, + state: StoreState = getState() +) { + return Object.values(getVariablesState(key, state).variables) .filter(filter) .sort((s1, s2) => s1.index - s2.index); -}; +} -export const getVariableWithName = (name: string, state: StoreState = getState()) => { - return getVariable(name, state, false); -}; +export function getVariablesState(key: string, state: StoreState = getState()): TemplatingState { + return state.templating.keys[toStateKey(key)] ?? getInitialTemplatingState(); +} -export const getVariables = (state: StoreState = getState()): VariableModel[] => { - return getFilteredVariables(defaultVariablesFilter, state); -}; +export function getVariablesByKey(key: string, state: StoreState = getState()): VariableModel[] { + return getFilteredVariablesByKey(defaultVariablesFilter, key, state); +} -export function defaultVariablesFilter(variable: VariableModel): boolean { +function defaultVariablesFilter(variable: VariableModel): boolean { return variable.type !== 'system'; } -export const getSubMenuVariables = memoizeOne((variables: Record): VariableModel[] => { - return getVariables(getState()); -}); +export const getSubMenuVariables = memoizeOne( + (key: string, variables: Record): VariableModel[] => { + return getVariablesByKey(key, getState()); + } +); -export const getEditorVariables = (state: StoreState): VariableModel[] => { - return getVariables(state); +export const getEditorVariables = (key: string, state: StoreState): VariableModel[] => { + return getVariablesByKey(key, state); }; -export type GetVariables = typeof getVariables; +export type GetVariables = typeof getVariablesByKey; -export function getNewVariableIndex(state: StoreState = getState()): number { - return getNextVariableIndex(Object.values(state.templating.variables)); +export function getNewVariableIndex(key: string, state: StoreState = getState()): number { + return getNextVariableIndex(Object.values(getVariablesState(key, state).variables)); } export function getNextVariableIndex(variables: VariableModel[]): number { @@ -55,6 +66,47 @@ export function getNextVariableIndex(variables: VariableModel[]): number { return sorted.length > 0 ? sorted[sorted.length - 1].index + 1 : 0; } -export function getVariablesIsDirty(state: StoreState = getState()): boolean { - return state.templating.transaction.isDirty; +export function getVariablesIsDirty(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.isDirty; +} + +export function getIfExistsLastKey(state: StoreState = getState()): string | undefined { + return state.templating?.lastKey; +} + +export function getLastKey(state: StoreState = getState()): string { + if (!state.templating?.lastKey) { + throw new Error('Accessing lastKey without initializing it variables'); + } + + return state.templating.lastKey; +} + +// selectors used by template srv, assumes that lastKey is in state. Needs to change when/if dashboard redux state becomes keyed too. +export function getFilteredVariables(filter: (model: VariableModel) => boolean, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getFilteredVariablesByKey(filter, lastKey, state); +} + +export function getVariables(state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getVariablesByKey(lastKey, state); +} + +export function getVariableWithName(name: string, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return; + } + return getVariable({ id: name, rootStateKey: lastKey, type: 'query' }, state, false); +} + +export function getInstanceState(state: VariablesState, id: string) { + return state[id] as Model; } diff --git a/public/app/features/variables/state/setOptionFromUrl.test.ts b/public/app/features/variables/state/setOptionFromUrl.test.ts index 0c5cd3a66a5..5128deefe8a 100644 --- a/public/app/features/variables/state/setOptionFromUrl.test.ts +++ b/public/app/features/variables/state/setOptionFromUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { createCustomVariableAdapter } from '../custom/adapter'; import { customBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from './reducers'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { addVariable, setCurrentVariableValue } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { setOptionFromUrl } from './actions'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -28,16 +28,31 @@ describe('when setOptionFromUrl is dispatched with a custom variable (no refresh ${null} | ${true} | ${['']} ${undefined} | ${true} | ${['']} `('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected, isMulti }) => { - const custom = customBuilder().withId('0').withMulti(isMulti).withOptions('A', 'B', 'C').withCurrent('A').build(); + const key = 'key'; + const custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti(isMulti) + .withOptions('A', 'B', 'C') + .withCurrent('A') + .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: expected, value: expected, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) + ) ) ); }); @@ -47,8 +62,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue contains same all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -56,16 +73,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + ) ) ) ); @@ -74,8 +96,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs from all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'X'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -83,14 +107,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + ) ) ); }); @@ -98,8 +127,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs but matches an option then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'B'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -107,14 +138,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + ) ) ); }); @@ -122,8 +158,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and custom all value matches an option', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -133,16 +171,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v custom.options[2].value = 'special value for .*'; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: '.*', value: 'special value for .*', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: '.*', value: 'special value for .*', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/sharedReducer.test.ts b/public/app/features/variables/state/sharedReducer.test.ts index 7721a92c583..ec1b2deb01b 100644 --- a/public/app/features/variables/state/sharedReducer.test.ts +++ b/public/app/features/variables/state/sharedReducer.test.ts @@ -17,7 +17,7 @@ import { variableStateNotStarted, } from './sharedReducer'; import { ConstantVariableModel, QueryVariableModel, VariableHide, VariableOption } from '../types'; -import { initialVariablesState, toVariablePayload, VariableIdentifier, VariablesState } from './types'; +import { initialVariablesState, KeyedVariableIdentifier, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { initialQueryVariableModelState } from '../query/reducer'; @@ -26,6 +26,7 @@ import { changeVariableNameSucceeded } from '../editor/reducer'; import { createConstantVariableAdapter } from '../constant/adapter'; import { initialConstantVariableModelState } from '../constant/reducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createQueryVariableAdapter(), createConstantVariableAdapter()]); @@ -102,6 +103,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -115,6 +117,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -140,6 +143,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -153,6 +157,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -179,6 +184,7 @@ describe('sharedReducer', () => { ...initialState, '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -192,6 +198,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -205,6 +212,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -219,6 +227,7 @@ describe('sharedReducer', () => { '11': { ...initialQueryVariableModelState, id: '11', + rootStateKey: 'key', name: 'copy_of_Name-1', index: 3, label: 'Label-1', @@ -237,6 +246,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -250,6 +260,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -263,6 +274,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -286,6 +298,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -299,6 +312,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -312,6 +326,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -548,7 +563,7 @@ describe('sharedReducer', () => { const constantAdapter = createConstantVariableAdapter(); const { initialState: constantAdapterState } = getVariableTestContext(constantAdapter); const newType = 'constant' as VariableType; - const identifier: VariableIdentifier = { id: '0', type: 'query' }; + const identifier: KeyedVariableIdentifier = { id: '0', type: 'query', rootStateKey: 'key' }; const payload = toVariablePayload(identifier, { newType }); reducerTester() .givenReducer(sharedReducer, cloneDeep(queryAdapterState)) @@ -564,6 +579,7 @@ describe('sharedReducer', () => { ...constantAdapterState, '0': { ...constantAdapterState[0], + rootStateKey: 'key', name: 'test', description: 'new description', label: 'new label', diff --git a/public/app/features/variables/state/sharedReducer.ts b/public/app/features/variables/state/sharedReducer.ts index 1a4225b14ad..7b5e4ecb6ce 100644 --- a/public/app/features/variables/state/sharedReducer.ts +++ b/public/app/features/variables/state/sharedReducer.ts @@ -2,11 +2,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { cloneDeep, defaults as lodashDefaults } from 'lodash'; import { LoadingState, VariableType } from '@grafana/data'; import { VariableModel, VariableOption, VariableWithOptions } from '../types'; -import { AddVariable, getInstanceState, initialVariablesState, VariablePayload, VariablesState } from './types'; +import { AddVariable, initialVariablesState, VariablePayload, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { changeVariableNameSucceeded } from '../editor/reducer'; import { ensureStringValues } from '../utils'; -import { getNextVariableIndex } from './selectors'; +import { getInstanceState, getNextVariableIndex } from './selectors'; const sharedReducerSlice = createSlice({ name: 'templating/shared', @@ -100,11 +100,12 @@ const sharedReducerSlice = createSlice({ }, changeVariableType: (state: VariablesState, action: PayloadAction>) => { const { id } = action.payload; - const { label, name, index, description } = state[id]; + const { label, name, index, description, rootStateKey } = state[id]; state[id] = { ...cloneDeep(variableAdapters.get(action.payload.data.newType).initialState), - id: id, + id, + rootStateKey: rootStateKey, label, name, index, diff --git a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts index c821aaafc63..7163f2c9bf0 100644 --- a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts +++ b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { constantBuilder, customBuilder } from '../shared/testing/builders'; import { DashboardState, StoreState } from '../../../types'; import { initialState } from '../../dashboard/state/reducers'; -import { TemplatingState } from './reducers'; import { ExtendedUrlQueryMap } from '../utils'; import { templateVarsChangedInUrl } from './actions'; import { createCustomVariableAdapter } from '../custom/adapter'; import { VariablesState } from './types'; import { DashboardModel } from '../../dashboard/state'; +import { getPreloadedState } from './helpers'; import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableModel } from '../types'; @@ -18,9 +18,11 @@ variableAdapters.setInit(() => [createCustomVariableAdapter(), createConstantVar async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: VariableModel | undefined = undefined) { jest.clearAllMocks(); + const key = 'key'; if (!variable) { variable = customBuilder() .withId('variable') + .withRootStateKey(key) .withName('variable') .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') @@ -43,15 +45,14 @@ async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: V }; const variables: VariablesState = { variable }; - const templating = { variables } as unknown as TemplatingState; const state: Partial = { dashboard, - templating, + ...getPreloadedState(key, { variables }), }; const getState = () => state as unknown as StoreState; const dispatch = jest.fn(); - const thunk = templateVarsChangedInUrl(urlQueryMap); + const thunk = templateVarsChangedInUrl(key, urlQueryMap); await thunk(dispatch, getState, undefined); @@ -125,6 +126,7 @@ describe('templateVarsChangedInUrl', () => { it('then the value should change to the value in dashboard json and dashboard should be refreshed', async () => { const constant = constantBuilder() .withId('variable') + .withRootStateKey('key') .withName('variable') .withQuery('default value in dash.json') .build(); diff --git a/public/app/features/variables/state/transactionReducer.ts b/public/app/features/variables/state/transactionReducer.ts index 974943a61b2..05d4a1c6cdf 100644 --- a/public/app/features/variables/state/transactionReducer.ts +++ b/public/app/features/variables/state/transactionReducer.ts @@ -52,14 +52,14 @@ const transactionSlice = createSlice({ }); function actionAffectsDirtyState(action: AnyAction): boolean { - return [ - removeVariable.type, - addVariable.type, - changeVariableProp.type, - changeVariableOrder.type, - duplicateVariable.type, - changeVariableType.type, - ].includes(action.type); + return ( + removeVariable.match(action) || + addVariable.match(action) || + changeVariableProp.match(action) || + changeVariableOrder.match(action) || + duplicateVariable.match(action) || + changeVariableType.match(action) + ); } export const { variablesInitTransaction, variablesClearTransaction, variablesCompleteTransaction } = diff --git a/public/app/features/variables/state/types.ts b/public/app/features/variables/state/types.ts index 0e652946b08..603f18753e0 100644 --- a/public/app/features/variables/state/types.ts +++ b/public/app/features/variables/state/types.ts @@ -6,15 +6,17 @@ export interface VariablesState extends Record {} export const initialVariablesState: VariablesState = {}; -export const getInstanceState = (state: VariablesState, id: string) => { - return state[id] as Model; -}; - export interface VariableIdentifier { type: VariableType; id: string; } +export interface KeyedVariableIdentifier { + type: VariableType; + id: string; + rootStateKey: string; +} + export interface VariablePayload extends VariableIdentifier { data: T; } @@ -24,21 +26,3 @@ export interface AddVariable { index: number; // the order in variables list model: T; } - -export const toVariableIdentifier = (variable: VariableModel): VariableIdentifier => { - return { type: variable.type, id: variable.id }; -}; - -export function toVariablePayload( - identifier: VariableIdentifier, - data?: T -): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload( - obj: VariableIdentifier | VariableModel, - data?: T -): VariablePayload { - return { type: obj.type, id: obj.id, data: data as T }; -} diff --git a/public/app/features/variables/state/upgradeLegacyQueries.test.ts b/public/app/features/variables/state/upgradeLegacyQueries.test.ts index 27a66721e74..d3f68d6e90d 100644 --- a/public/app/features/variables/state/upgradeLegacyQueries.test.ts +++ b/public/app/features/variables/state/upgradeLegacyQueries.test.ts @@ -1,10 +1,12 @@ import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { VariableSupportType } from '@grafana/data'; -import { toVariableIdentifier } from './types'; import { upgradeLegacyQueries } from './actions'; import { changeVariableProp } from './sharedReducer'; import { thunkTester } from '../../../../test/core/thunk/thunkTester'; import { TransactionStatus, VariableModel } from '../types'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toKeyedVariableIdentifier } from '../utils'; interface Args { query?: any; @@ -18,22 +20,23 @@ function getTestContext({ datasource, transactionStatus = TransactionStatus.Fetching, }: Args = {}) { + const key = 'key'; variable = variable ?? queryBuilder() .withId('query') + .withRootStateKey(key) .withName('query') .withQuery(query) .withDatasource({ uid: 'test-data', type: 'test-data' }) .build(); - const state = { - templating: { - transaction: { status: transactionStatus }, - variables: { - [variable.id]: variable, - }, + const templatingState = { + transaction: { status: transactionStatus, uid: key, isDirty: false }, + variables: { + [variable.id]: variable, }, }; + const state = getPreloadedState(key, templatingState); datasource = datasource ?? { name: 'TestData', metricFindQuery: () => undefined, @@ -41,32 +44,35 @@ function getTestContext({ }; const get = jest.fn().mockResolvedValue(datasource); const getDatasourceSrv = jest.fn().mockReturnValue({ get }); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); - return { state, get, getDatasourceSrv, identifier }; + return { key, state, get, getDatasourceSrv, identifier }; } describe('upgradeLegacyQueries', () => { describe('when called with a query variable for a standard variable supported data source that has not been upgraded', () => { it('then it should dispatch changeVariableProp', async () => { - const { state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); + const { key, state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); const dispatchedActions = await thunkTester(state) .givenThunk(upgradeLegacyQueries) .whenThunkIsDispatched(identifier, getDatasourceSrv); expect(dispatchedActions).toEqual([ - changeVariableProp({ - type: 'query', - id: 'query', - data: { - propName: 'query', - propValue: { - refId: 'TestData-query-Variable-Query', - query: '*', + toKeyedAction( + key, + changeVariableProp({ + type: 'query', + id: 'query', + data: { + propName: 'query', + propValue: { + refId: 'TestData-query-Variable-Query', + query: '*', + }, }, - }, - }), + }) + ), ]); expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith({ uid: 'test-data', type: 'test-data' }); @@ -161,7 +167,7 @@ describe('upgradeLegacyQueries', () => { describe('when called with a custom variable', () => { it('then it should not dispatch any actions', async () => { - const variable = customBuilder().withId('custom').withName('custom').build(); + const variable = customBuilder().withId('custom').withRootStateKey('key').withName('custom').build(); const { state, identifier, get, getDatasourceSrv } = getTestContext({ variable }); const dispatchedActions = await thunkTester(state) diff --git a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx index cc1dbaf9743..7ad4152683d 100644 --- a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx +++ b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx @@ -1,12 +1,13 @@ import React, { ChangeEvent, FocusEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useState } from 'react'; import { TextBoxVariableModel } from '../types'; -import { toVariablePayload } from '../state/types'; import { changeVariableProp } from '../state/sharedReducer'; import { VariablePickerProps } from '../pickers/types'; import { Input } from '@grafana/ui'; import { variableAdapters } from '../adapters'; import { useDispatch } from 'react-redux'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface Props extends VariablePickerProps {} @@ -18,13 +19,21 @@ export function TextBoxVariablePicker({ variable, onVariableChange }: Props): Re }, [variable]); const updateVariable = useCallback(() => { + if (!variable.rootStateKey) { + console.error('Cannot update variable without rootStateKey'); + return; + } + if (variable.current.value === updatedValue) { return; } dispatch( - changeVariableProp( - toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + toKeyedAction( + variable.rootStateKey, + changeVariableProp( + toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + ) ) ); diff --git a/public/app/features/variables/textbox/actions.test.ts b/public/app/features/variables/textbox/actions.test.ts index e605a69e80b..4c38b863523 100644 --- a/public/app/features/variables/textbox/actions.test.ts +++ b/public/app/features/variables/textbox/actions.test.ts @@ -4,11 +4,12 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createTextBoxOptions } from './reducer'; import { addVariable, changeVariableProp, setCurrentVariableValue } from '../state/sharedReducer'; import { textboxBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); @@ -32,16 +33,25 @@ describe('textbox actions', () => { selected: false, }; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createTextBoxOptions(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction(key, createTextBoxOptions(toVariablePayload(variable))), + toKeyedAction(key, setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-textbox': 'A' }); }); @@ -50,16 +60,31 @@ describe('textbox actions', () => { describe('when setTextBoxVariableOptionsFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { const urlValue = 'bB'; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(setTextBoxVariableOptionsFromUrl(toVariablePayload(variable), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched( + setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue), + true + ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' })), - setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + toKeyedAction(key, changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' }))), + toKeyedAction( + key, + setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + ) ); }); }); diff --git a/public/app/features/variables/textbox/actions.ts b/public/app/features/variables/textbox/actions.ts index 662f45b4791..4e10c499168 100644 --- a/public/app/features/variables/textbox/actions.ts +++ b/public/app/features/variables/textbox/actions.ts @@ -3,28 +3,36 @@ import { ThunkResult } from '../../../types'; import { getVariable } from '../state/selectors'; import { variableAdapters } from '../adapters'; import { createTextBoxOptions } from './reducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { setOptionFromUrl } from '../state/actions'; import { UrlQueryValue } from '@grafana/data'; import { changeVariableProp } from '../state/sharedReducer'; -import { ensureStringValues } from '../utils'; +import { ensureStringValues, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; -export const updateTextBoxVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateTextBoxVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - await dispatch(createTextBoxOptions(toVariablePayload(identifier))); + const { rootStateKey, type } = identifier; + dispatch(toKeyedAction(rootStateKey, createTextBoxOptions(toVariablePayload(identifier)))); - const variableInState = getVariable(identifier.id, getState()); - await variableAdapters.get(identifier.type).setValue(variableInState, variableInState.options[0], true); + const variableInState = getVariable(identifier, getState()); + await variableAdapters.get(type).setValue(variableInState, variableInState.options[0], true); }; }; export const setTextBoxVariableOptionsFromUrl = - (identifier: VariableIdentifier, urlValue: UrlQueryValue): ThunkResult => + (identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); const stringUrlValue = ensureStringValues(urlValue); - dispatch(changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue })) + ) + ); - await dispatch(setOptionFromUrl(toVariableIdentifier(variableInState), stringUrlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variableInState), stringUrlValue)); }; diff --git a/public/app/features/variables/textbox/adapter.test.ts b/public/app/features/variables/textbox/adapter.test.ts index e2e9d1db7ba..f28b7fd5c7c 100644 --- a/public/app/features/variables/textbox/adapter.test.ts +++ b/public/app/features/variables/textbox/adapter.test.ts @@ -11,6 +11,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') @@ -41,6 +42,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') diff --git a/public/app/features/variables/textbox/adapter.ts b/public/app/features/variables/textbox/adapter.ts index c910867f020..a916520eaca 100644 --- a/public/app/features/variables/textbox/adapter.ts +++ b/public/app/features/variables/textbox/adapter.ts @@ -8,7 +8,7 @@ import { VariableAdapter } from '../adapters'; import { TextBoxVariablePicker } from './TextBoxVariablePicker'; import { TextBoxVariableEditor } from './TextBoxVariableEditor'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; +import { toKeyedVariableIdentifier } from '../utils'; export const createTextBoxVariableAdapter = (): VariableAdapter => { return { @@ -23,16 +23,16 @@ export const createTextBoxVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setTextBoxVariableOptionsFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable, saveCurrentAsDefault) => { - const { index, id, state, global, originalQuery, ...rest } = cloneDeep(variable); + const { index, id, state, global, originalQuery, rootStateKey, ...rest } = cloneDeep(variable); if (variable.query !== originalQuery && !saveCurrentAsDefault) { const origQuery = originalQuery ?? ''; diff --git a/public/app/features/variables/textbox/reducer.test.ts b/public/app/features/variables/textbox/reducer.test.ts index fc4c4663856..fbd83a44318 100644 --- a/public/app/features/variables/textbox/reducer.test.ts +++ b/public/app/features/variables/textbox/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createTextBoxOptions, textBoxVariableReducer } from './reducer'; import { TextBoxVariableModel } from '../types'; import { createTextBoxVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('textBoxVariableReducer', () => { const adapter = createTextBoxVariableAdapter(); diff --git a/public/app/features/variables/textbox/reducer.ts b/public/app/features/variables/textbox/reducer.ts index d8850d80225..405cde2da91 100644 --- a/public/app/features/variables/textbox/reducer.ts +++ b/public/app/features/variables/textbox/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, TextBoxVariableModel, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialTextBoxVariableModelState: TextBoxVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index 0f8b6835dc9..5d894ab35b8 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -131,6 +131,7 @@ export interface SystemVariable string }> exten export interface VariableModel extends BaseVariableModel { id: string; + rootStateKey: string | null; global: boolean; hide: VariableHide; skipUrlSync: boolean; @@ -142,6 +143,7 @@ export interface VariableModel extends BaseVariableModel { export const initialVariableModelState: VariableModel = { id: NEW_VARIABLE_ID, + rootStateKey: null, name: '', label: null, type: '' as unknown as VariableType, diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index 96068a50272..d6964a6aa67 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -9,6 +9,8 @@ import { variableAdapters } from './adapters'; import { safeStringifyValue } from 'app/core/utils/explore'; import { StoreState } from '../../types'; import { getState } from '../../store/store'; +import { getVariablesState } from './state/selectors'; +import { KeyedVariableIdentifier, VariableIdentifier, VariablePayload } from './state/types'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -256,6 +258,32 @@ export function ensureStringValues(value: any | any[]): string | string[] { return ''; } -export function hasOngoingTransaction(state: StoreState = getState()): boolean { - return state.templating.transaction.status !== TransactionStatus.NotStarted; +export function hasOngoingTransaction(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.status !== TransactionStatus.NotStarted; +} + +export function toStateKey(key: string | null | undefined): string { + return String(key); +} + +export const toKeyedVariableIdentifier = (variable: VariableModel): KeyedVariableIdentifier => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + return { type: variable.type, id: variable.id, rootStateKey: variable.rootStateKey }; +}; + +export function toVariablePayload( + identifier: VariableIdentifier, + data?: T +): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload( + obj: VariableIdentifier | VariableModel, + data?: T +): VariablePayload { + return { type: obj.type, id: obj.id, data: data as T }; } diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index d8d06ff56ec..2db9cbf4f52 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -11,12 +11,12 @@ import * as redux from 'app/store/store'; import { CloudWatchDatasource } from '../datasource'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { - MetricEditorMode, CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, LogAction, + MetricEditorMode, MetricQueryType, } from '../types'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ @@ -705,9 +705,11 @@ describe('CloudWatchDatasource', () => { describe('When performing CloudWatch query with template variables', () => { let templateSrv: TemplateSrv; beforeEach(() => { + const key = 'key'; const var1: CustomVariableModel = { ...initialVariableModelState, id: 'var1', + rootStateKey: key, name: 'var1', index: 0, current: { value: 'var1-foo', text: 'var1-foo', selected: true }, @@ -721,6 +723,7 @@ describe('CloudWatchDatasource', () => { const var2: CustomVariableModel = { ...initialVariableModelState, id: 'var2', + rootStateKey: key, name: 'var2', index: 1, current: { value: 'var2-foo', text: 'var2-foo', selected: true }, @@ -734,6 +737,7 @@ describe('CloudWatchDatasource', () => { const var3: CustomVariableModel = { ...initialVariableModelState, id: 'var3', + rootStateKey: key, name: 'var3', index: 2, current: { value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz', selected: true }, @@ -751,6 +755,7 @@ describe('CloudWatchDatasource', () => { const var4: CustomVariableModel = { ...initialVariableModelState, id: 'var4', + rootStateKey: key, name: 'var4', index: 3, options: [ @@ -766,7 +771,7 @@ describe('CloudWatchDatasource', () => { type: 'custom', }; const variables = [var1, var2, var3, var4]; - const state = convertToStoreState(variables); + const state = convertToStoreState(key, variables); templateSrv = new TemplateSrv(getTemplateSrvDependencies(state)); templateSrv.init(variables); }); diff --git a/public/app/store/store.ts b/public/app/store/store.ts index a00442011fa..496a3e4b081 100644 --- a/public/app/store/store.ts +++ b/public/app/store/store.ts @@ -1,6 +1,8 @@ -import { StoreState } from 'app/types'; import { Store } from 'redux'; +import { initialKeyedVariablesState } from 'app/features/variables/state/keyedVariablesReducer'; +import { StoreState } from 'app/types'; + export let store: Store; export function setStore(newStore: Store) { @@ -9,11 +11,7 @@ export function setStore(newStore: Store) { export function getState(): StoreState { if (!store || !store.getState) { - return { - templating: { - variables: {}, - }, - } as StoreState; // used by tests + return { templating: { ...initialKeyedVariablesState, lastKey: 'key' } } as StoreState; // used by tests } return store.getState(); diff --git a/public/test/helpers/convertToStoreState.ts b/public/test/helpers/convertToStoreState.ts index c8c51dc6b9d..40bbe568e91 100644 --- a/public/test/helpers/convertToStoreState.ts +++ b/public/test/helpers/convertToStoreState.ts @@ -1,12 +1,12 @@ import { StoreState } from '../../app/types'; +import { getPreloadedState } from '../../app/features/variables/state/helpers'; -export const convertToStoreState = (variables: any[]): StoreState => { +export const convertToStoreState = (key: string, models: any[]): StoreState => { + const variables = models.reduce((byName, variable) => { + byName[variable.name] = variable; + return byName; + }, {}); return { - templating: { - variables: variables.reduce((byName, variable) => { - byName[variable.name] = variable; - return byName; - }, {}), - }, + ...getPreloadedState(key, { variables }), } as StoreState; }; diff --git a/public/test/helpers/initTemplateSrv.ts b/public/test/helpers/initTemplateSrv.ts index 2af808175eb..a19688f282c 100644 --- a/public/test/helpers/initTemplateSrv.ts +++ b/public/test/helpers/initTemplateSrv.ts @@ -3,8 +3,8 @@ import { convertToStoreState } from './convertToStoreState'; import { TemplateSrv } from '../../app/features/templating/template_srv'; import { getTemplateSrvDependencies } from './getTemplateSrvDependencies'; -export function initTemplateSrv(variables: any[], timeRange?: TimeRange) { - const state = convertToStoreState(variables); +export function initTemplateSrv(key: string, variables: any[], timeRange?: TimeRange) { + const state = convertToStoreState(key, variables); const srv = new TemplateSrv(getTemplateSrvDependencies(state)); srv.init(variables, timeRange);