Variables: Adds loading state and indicators (#27917)

* Refactor: Replaces initLock with state machine

* Refactor: removes some states for now

* Refactor: adds loading state in OptionsPicker

* Refactor: major refactor of load state

* Refactor: fixes updating graph in parallell

* Refactor: moves error handling to updateOptions

* Refactor: fixes the last cases

* Tests: disables variable e2e again

* Chore: removes nova config

* Refactor: small changes when going through the code again

* Refactor: fixes typings

* Refactor: changes after PR comments

* Refactor: split up onTimeRangeUpdated and fixed some error handling

* Tests: removes unused func

* Tests: fixes typing
This commit is contained in:
Hugo Häggmark 2020-10-02 07:02:06 +02:00 committed by GitHub
parent add777ad40
commit 845bc7c444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 892 additions and 785 deletions

View File

@ -30,15 +30,11 @@ describe.skip('Variables', () => {
if (!lastUid || !lastData) { if (!lastUid || !lastData) {
e2e.flows.addDataSource(); e2e.flows.addDataSource();
e2e.flows.addDashboard(); e2e.flows.addDashboard();
lastUid = 'test';
lastData = 'test';
} else { } else {
e2e.setScenarioContext({ lastAddedDataSource: lastData, lastAddedDashboardUid: lastUid }); e2e.flows.openDashboard();
} }
e2e.getScenarioContext().then(({ lastAddedDashboardUid, lastAddedDataSource }: any) => {
e2e.flows.openDashboard({ uid: lastAddedDashboardUid });
lastUid = lastAddedDashboardUid;
lastData = lastAddedDataSource;
});
}); });
it(`asserts defaults`, () => { it(`asserts defaults`, () => {
@ -254,7 +250,7 @@ const createQueryVariable = ({ name, label, dataSourceName, query }: CreateQuery
expect(input.attr('placeholder')).equals('blank = auto'); expect(input.attr('placeholder')).equals('blank = auto');
expect(input.val()).equals(''); expect(input.val()).equals('');
}); });
e2e.pages.Dashboard.Settings.Variables.Edit.General.addButton().click(); e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
}; };
const assertVariableLabelAndComponent = ({ label, options, selectedOption }: VariablesData) => { const assertVariableLabelAndComponent = ({ label, options, selectedOption }: VariablesData) => {

View File

@ -85,8 +85,7 @@ export const Pages = {
selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch', selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch',
selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field', selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field',
previewOfValuesOption: 'Variable editor Preview of Values option', previewOfValuesOption: 'Variable editor Preview of Values option',
addButton: 'Variable editor Add button', submitButton: 'Variable editor Submit button',
updateButton: 'Variable editor Update button',
}, },
QueryVariable: { QueryVariable: {
queryOptionsDataSourceSelect: 'Variable editor Form Query DataSource select', queryOptionsDataSourceSelect: 'Variable editor Form Query DataSource select',

View File

@ -217,7 +217,7 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar
} }
}); });
e2e.pages.Dashboard.Settings.Variables.Edit.General.addButton().click(); e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
return fullConfig; return fullConfig;
}; };

View File

@ -28,7 +28,7 @@ export const createAdHocVariableAdapter = (): VariableAdapter<AdHocVariableModel
}, },
updateOptions: noop, updateOptions: noop,
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest; return rest;
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,5 +1,5 @@
import { AdHocVariableFilter, AdHocVariableModel, VariableHide } from 'app/features/variables/types'; import { AdHocVariableFilter, AdHocVariableModel, initialVariableModelState } from 'app/features/variables/types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types'; import { getInstanceState, VariablePayload } from '../state/types';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
@ -13,15 +13,8 @@ export interface AdHocVariableEditorState {
} }
export const initialAdHocVariableModelState: AdHocVariableModel = { export const initialAdHocVariableModelState: AdHocVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
type: 'adhoc', type: 'adhoc',
name: '',
hide: VariableHide.dontHide,
label: '',
skipUrlSync: false,
index: -1,
initLock: null,
datasource: null, datasource: null,
filters: [], filters: [],
}; };

View File

@ -4,7 +4,7 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from 'app/features/variables/state/reducers'; import { TemplatingState } from 'app/features/variables/state/reducers';
import { updateConstantVariableOptions } from './actions'; import { updateConstantVariableOptions } from './actions';
import { getRootReducer } from '../state/helpers'; import { getRootReducer } from '../state/helpers';
import { ConstantVariableModel, VariableHide, VariableOption } from '../types'; import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types';
import { toVariablePayload } from '../state/types'; import { toVariablePayload } from '../state/types';
import { createConstantOptionsFromQuery } from './reducer'; import { createConstantOptionsFromQuery } from './reducer';
import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer';
@ -21,9 +21,11 @@ describe('constant actions', () => {
}; };
const variable: ConstantVariableModel = { const variable: ConstantVariableModel = {
type: 'constant', ...initialVariableModelState,
id: '0', id: '0',
global: false, index: 0,
type: 'constant',
name: 'Constant',
current: { current: {
value: '', value: '',
text: '', text: '',
@ -31,11 +33,6 @@ describe('constant actions', () => {
}, },
options: [], options: [],
query: 'A', query: 'A',
name: 'Constant',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
}; };
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()

View File

@ -31,7 +31,7 @@ export const createConstantVariableAdapter = (): VariableAdapter<ConstantVariabl
await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable))); await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable)));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest; return rest;
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,21 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ConstantVariableModel, VariableHide, VariableOption } from '../types'; import { ConstantVariableModel, initialVariableModelState, VariableHide, VariableOption } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types'; import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialConstantVariableModelState: ConstantVariableModel = { export const initialConstantVariableModelState: ConstantVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
type: 'constant', type: 'constant',
name: '',
hide: VariableHide.hideVariable, hide: VariableHide.hideVariable,
label: '',
query: '', query: '',
current: {} as VariableOption, current: {} as VariableOption,
options: [], options: [],
skipUrlSync: false,
index: -1,
initLock: null,
}; };
export const constantVariableSlice = createSlice({ export const constantVariableSlice = createSlice({

View File

@ -3,7 +3,7 @@ import { updateCustomVariableOptions } from './actions';
import { createCustomVariableAdapter } from './adapter'; import { createCustomVariableAdapter } from './adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../state/helpers'; import { getRootReducer } from '../state/helpers';
import { CustomVariableModel, VariableHide, VariableOption } from '../types'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types';
import { toVariablePayload } from '../state/types'; import { toVariablePayload } from '../state/types';
import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer';
import { TemplatingState } from '../state/reducers'; import { TemplatingState } from '../state/reducers';
@ -21,9 +21,11 @@ describe('custom actions', () => {
}; };
const variable: CustomVariableModel = { const variable: CustomVariableModel = {
type: 'custom', ...initialVariableModelState,
id: '0', id: '0',
global: false, index: 0,
type: 'custom',
name: 'Custom',
current: { current: {
value: '', value: '',
text: '', text: '',
@ -42,11 +44,6 @@ describe('custom actions', () => {
}, },
], ],
query: 'A,B', query: 'A,B',
name: 'Custom',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
multi: true, multi: true,
includeAll: false, includeAll: false,
}; };

View File

@ -32,7 +32,7 @@ export const createCustomVariableAdapter = (): VariableAdapter<CustomVariableMod
await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable))); await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable)));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest; return rest;
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,31 +1,18 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { CustomVariableModel, VariableHide, VariableOption } from '../types'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types';
import { import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, getInstanceState, VariablePayload } from '../state/types';
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
NEW_VARIABLE_ID,
VariablePayload,
} from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialCustomVariableModelState: CustomVariableModel = { export const initialCustomVariableModelState: CustomVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false, type: 'custom',
multi: false, multi: false,
includeAll: false, includeAll: false,
allValue: null, allValue: null,
query: '', query: '',
options: [], options: [],
current: {} as VariableOption, current: {} as VariableOption,
name: '',
type: 'custom',
label: null,
hide: VariableHide.dontHide,
skipUrlSync: false,
index: -1,
initLock: null,
}; };
export const customVariableSlice = createSlice({ export const customVariableSlice = createSlice({

View File

@ -35,7 +35,7 @@ export const createDataSourceVariableAdapter = (): VariableAdapter<DataSourceVar
await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable))); await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable)));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return { ...rest, options: [] }; return { ...rest, options: [] };
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,12 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { DataSourceVariableModel, VariableHide, VariableOption, VariableRefresh } from '../types'; import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types';
import { import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, getInstanceState, VariablePayload } from '../state/types';
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
NEW_VARIABLE_ID,
VariablePayload,
} from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
import { DataSourceSelectItem } from '@grafana/data'; import { DataSourceSelectItem } from '@grafana/data';
@ -15,12 +9,8 @@ export interface DataSourceVariableEditorState {
} }
export const initialDataSourceVariableModelState: DataSourceVariableModel = { export const initialDataSourceVariableModelState: DataSourceVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
type: 'datasource', type: 'datasource',
name: '',
hide: VariableHide.dontHide,
label: '',
current: {} as VariableOption, current: {} as VariableOption,
regex: '', regex: '',
options: [], options: [],
@ -28,9 +18,6 @@ export const initialDataSourceVariableModelState: DataSourceVariableModel = {
multi: false, multi: false,
includeAll: false, includeAll: false,
refresh: VariableRefresh.onDashboardLoad, refresh: VariableRefresh.onDashboardLoad,
skipUrlSync: false,
index: -1,
initLock: null,
}; };
export const dataSourceVariableSlice = createSlice({ export const dataSourceVariableSlice = createSlice({

View File

@ -1,11 +1,11 @@
import React, { ChangeEvent, FormEvent, PureComponent } from 'react'; import React, { ChangeEvent, FormEvent, PureComponent } from 'react';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { AppEvents, VariableType } from '@grafana/data'; import { AppEvents, LoadingState, VariableType } from '@grafana/data';
import { InlineFormLabel } from '@grafana/ui'; import { Icon, InlineFormLabel } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { NEW_VARIABLE_ID, toVariablePayload, VariableIdentifier } from '../state/types'; import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types';
import { VariableHide, VariableModel } from '../types'; import { VariableHide, VariableModel } from '../types';
import { appEvents } from '../../../core/core'; import { appEvents } from '../../../core/core';
import { VariableValuesPreview } from './VariableValuesPreview'; import { VariableValuesPreview } from './VariableValuesPreview';
@ -17,6 +17,7 @@ import { getVariable } from '../state/selectors';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
import { OnPropChangeArguments } from './types'; import { OnPropChangeArguments } from './types';
import { changeVariableProp, changeVariableType } from '../state/sharedReducer'; import { changeVariableProp, changeVariableType } from '../state/sharedReducer';
import { updateOptions } from '../state/actions';
export interface OwnProps { export interface OwnProps {
identifier: VariableIdentifier; identifier: VariableIdentifier;
@ -35,6 +36,7 @@ interface DispatchProps {
onEditorUpdate: typeof onEditorUpdate; onEditorUpdate: typeof onEditorUpdate;
onEditorAdd: typeof onEditorAdd; onEditorAdd: typeof onEditorAdd;
changeVariableType: typeof changeVariableType; changeVariableType: typeof changeVariableType;
updateOptions: typeof updateOptions;
} }
type Props = OwnProps & ConnectedProps & DispatchProps; type Props = OwnProps & ConnectedProps & DispatchProps;
@ -88,7 +90,7 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => { onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => {
this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue })); this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue }));
if (updateOptions) { if (updateOptions) {
await variableAdapters.get(this.props.variable.type).updateOptions(this.props.variable); await this.props.updateOptions(toVariableIdentifier(this.props.variable));
} }
}; };
@ -108,11 +110,13 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
}; };
render() { render() {
const { variable } = this.props;
const EditorToRender = variableAdapters.get(this.props.variable.type).editor; const EditorToRender = variableAdapters.get(this.props.variable.type).editor;
if (!EditorToRender) { if (!EditorToRender) {
return null; return null;
} }
const newVariable = this.props.variable.id && this.props.variable.id === NEW_VARIABLE_ID; const newVariable = this.props.variable.id && this.props.variable.id === NEW_VARIABLE_ID;
const loading = variable.state === LoadingState.Loading;
return ( return (
<div> <div>
@ -201,24 +205,15 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
<VariableValuesPreview variable={this.props.variable} /> <VariableValuesPreview variable={this.props.variable} />
<div className="gf-form-button-row p-y-0"> <div className="gf-form-button-row p-y-0">
{!newVariable && (
<button <button
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.updateButton} aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton}
disabled={loading}
> >
Update {newVariable ? 'Add' : 'Update'}
{loading ? <Icon className="spin-clockwise" name="sync" size="sm" style={{ marginLeft: '2px' }} /> : null}
</button> </button>
)}
{newVariable && (
<button
type="submit"
className="btn btn-primary"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.addButton}
>
Add
</button>
)}
</div> </div>
</form> </form>
</div> </div>
@ -239,6 +234,7 @@ const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
onEditorUpdate, onEditorUpdate,
onEditorAdd, onEditorAdd,
changeVariableType, changeVariableType,
updateOptions,
}; };
export const VariableEditorEditor = connectWithStore( export const VariableEditorEditor = connectWithStore(

View File

@ -19,6 +19,7 @@ import {
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { VariableType } from '@grafana/data'; import { VariableType } from '@grafana/data';
import { addVariable, removeVariable, storeNewVariable } from '../state/sharedReducer'; import { addVariable, removeVariable, storeNewVariable } from '../state/sharedReducer';
import { updateOptions } from '../state/actions';
export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult<void> => { export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult<void> => {
return async dispatch => { return async dispatch => {
@ -36,9 +37,8 @@ export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResu
}; };
export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult<void> => { export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult<void> => {
return async (dispatch, getState) => { return async dispatch => {
const variableInState = getVariable(identifier.id, getState()); await dispatch(updateOptions(identifier));
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
dispatch(switchToListMode()); dispatch(switchToListMode());
}; };
}; };
@ -48,8 +48,7 @@ export const onEditorAdd = (identifier: VariableIdentifier): ThunkResult<void> =
const newVariableInState = getVariable(NEW_VARIABLE_ID, getState()); const newVariableInState = getVariable(NEW_VARIABLE_ID, getState());
const id = newVariableInState.name; const id = newVariableInState.name;
dispatch(storeNewVariable(toVariablePayload({ type: identifier.type, id }))); dispatch(storeNewVariable(toVariablePayload({ type: identifier.type, id })));
const variableInState = getVariable(id, getState()); await dispatch(updateOptions(identifier));
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
dispatch(switchToListMode()); dispatch(switchToListMode());
dispatch(removeVariable(toVariablePayload({ type: identifier.type, id: NEW_VARIABLE_ID }, { reIndex: false }))); dispatch(removeVariable(toVariablePayload({ type: identifier.type, id: NEW_VARIABLE_ID }, { reIndex: false })));
}; };

View File

@ -2,22 +2,23 @@ import { getRootReducer } from '../state/helpers';
import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from '../state/reducers'; import { TemplatingState } from '../state/reducers';
import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { toVariableIdentifier, toVariablePayload } from '../state/types';
import { import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions';
updateAutoValue,
UpdateAutoValueDependencies,
updateIntervalVariableOptions,
UpdateIntervalVariableOptionsDependencies,
} from './actions';
import { createIntervalOptions } from './reducer'; import { createIntervalOptions } from './reducer';
import { setCurrentVariableValue, addVariable } from '../state/sharedReducer'; import {
addVariable,
setCurrentVariableValue,
variableStateFailed,
variableStateFetching,
} from '../state/sharedReducer';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { createIntervalVariableAdapter } from './adapter'; import { createIntervalVariableAdapter } from './adapter';
import { Emitter } from 'app/core/core'; import { dateTime } from '@grafana/data';
import { AppEvents, dateTime } from '@grafana/data';
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { TemplateSrv } from '../../templating/template_srv'; import { TemplateSrv } from '../../templating/template_srv';
import { intervalBuilder } from '../shared/testing/builders'; import { intervalBuilder } from '../shared/testing/builders';
import kbn from 'app/core/utils/kbn'; import { updateOptions } from '../state/actions';
import { notifyApp } from '../../../core/actions';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
describe('interval actions', () => { describe('interval actions', () => {
variableAdapters.setInit(() => [createIntervalVariableAdapter()]); variableAdapters.setInit(() => [createIntervalVariableAdapter()]);
@ -45,8 +46,9 @@ describe('interval actions', () => {
}); });
}); });
describe('when updateIntervalVariableOptions is dispatched but something throws', () => { describe('when updateOptions is dispatched but something throws', () => {
it('then an app event should be emitted', async () => { silenceConsoleOutput();
it('then an notifyApp action should be dispatched', async () => {
const timeSrvMock = ({ const timeSrvMock = ({
timeRange: jest.fn().mockReturnValue({ timeRange: jest.fn().mockReturnValue({
from: dateTime(new Date()) from: dateTime(new Date())
@ -67,23 +69,36 @@ describe('interval actions', () => {
.withAuto(true) .withAuto(true)
.withAutoMin('1xyz') // illegal interval string .withAutoMin('1xyz') // illegal interval string
.build(); .build();
const appEventMock = ({
emit: jest.fn(),
} as unknown) as Emitter;
const dependencies: UpdateIntervalVariableOptionsDependencies = { appEvents: appEventMock };
await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getRootReducer()) .givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })))
.whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval), dependencies), true); .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true);
tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
const expectedNumberOfActions = 4;
expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(interval)));
expect(dispatchedActions[1]).toEqual(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"'
),
})
)
);
expect(dispatchedActions[3].type).toEqual(notifyApp.type);
expect(dispatchedActions[3].payload.title).toEqual('Templating [0]');
expect(dispatchedActions[3].payload.text).toEqual(
'Error updating options: 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"'
);
expect(dispatchedActions[3].payload.severity).toEqual('error');
return dispatchedActions.length === expectedNumberOfActions;
});
expect(appEventMock.emit).toHaveBeenCalledTimes(1);
expect(appEventMock.emit).toHaveBeenCalledWith(AppEvents.alertError, [
'Templating',
`Invalid interval string, has to be either unit-less or end with one of the following units: "${Object.keys(
kbn.intervalsInSeconds
).join(', ')}"`,
]);
setTimeSrv(originalTimeSrv); setTimeSrv(originalTimeSrv);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { AppEvents, rangeUtil } from '@grafana/data'; import { rangeUtil } from '@grafana/data';
import { toVariablePayload, VariableIdentifier } from '../state/types'; import { toVariablePayload, VariableIdentifier } from '../state/types';
import { ThunkResult } from '../../../types'; import { ThunkResult } from '../../../types';
@ -8,23 +8,11 @@ import { getVariable } from '../state/selectors';
import { IntervalVariableModel } from '../types'; import { IntervalVariableModel } from '../types';
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv';
import appEvents from '../../../core/app_events';
export interface UpdateIntervalVariableOptionsDependencies { export const updateIntervalVariableOptions = (identifier: VariableIdentifier): ThunkResult<void> => async dispatch => {
appEvents: typeof appEvents;
}
export const updateIntervalVariableOptions = (
identifier: VariableIdentifier,
dependencies: UpdateIntervalVariableOptionsDependencies = { appEvents: appEvents }
): ThunkResult<void> => async dispatch => {
try {
await dispatch(createIntervalOptions(toVariablePayload(identifier))); await dispatch(createIntervalOptions(toVariablePayload(identifier)));
await dispatch(updateAutoValue(identifier)); await dispatch(updateAutoValue(identifier));
await dispatch(validateVariableSelectionState(identifier)); await dispatch(validateVariableSelectionState(identifier));
} catch (error) {
dependencies.appEvents.emit(AppEvents.alertError, ['Templating', error.message]);
}
}; };
export interface UpdateAutoValueDependencies { export interface UpdateAutoValueDependencies {

View File

@ -33,7 +33,7 @@ export const createIntervalVariableAdapter = (): VariableAdapter<IntervalVariabl
await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable))); await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable)));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest; return rest;
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,17 +1,12 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IntervalVariableModel, VariableHide, VariableOption, VariableRefresh } from '../types'; import { initialVariableModelState, IntervalVariableModel, VariableOption, VariableRefresh } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types'; import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
import _ from 'lodash'; import _ from 'lodash';
export const initialIntervalVariableModelState: IntervalVariableModel = { export const initialIntervalVariableModelState: IntervalVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
type: 'interval', type: 'interval',
name: '',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
auto_count: 30, auto_count: 30,
auto_min: '10s', auto_min: '10s',
options: [], options: [],
@ -19,8 +14,6 @@ export const initialIntervalVariableModelState: IntervalVariableModel = {
query: '1m,10m,30m,1h,6h,12h,1d,7d,14d,30d', query: '1m,10m,30m,1h,6h,12h,1d,7d,14d,30d',
refresh: VariableRefresh.onTimeRangeChanged, refresh: VariableRefresh.onTimeRangeChanged,
current: {} as VariableOption, current: {} as VariableOption,
index: -1,
initLock: null,
}; };
export const intervalVariableSlice = createSlice({ export const intervalVariableSlice = createSlice({

View File

@ -11,6 +11,7 @@ import { VariableOptions } from '../shared/VariableOptions';
import { isQuery } from '../../guard'; import { isQuery } from '../../guard';
import { VariablePickerProps } from '../types'; import { VariablePickerProps } from '../types';
import { formatVariableLabel } from '../../shared/formatVariable'; import { formatVariableLabel } from '../../shared/formatVariable';
import { LoadingState } from '@grafana/data';
interface OwnProps extends VariablePickerProps<VariableWithMultiSupport> {} interface OwnProps extends VariablePickerProps<VariableWithMultiSupport> {}
@ -67,8 +68,9 @@ export class OptionsPickerUnconnected extends PureComponent<Props> {
const linkText = formatVariableLabel(variable); const linkText = formatVariableLabel(variable);
const tags = getSelectedTags(variable); const tags = getSelectedTags(variable);
const loading = variable.state === LoadingState.Loading;
return <VariableLink text={linkText} tags={tags} onClick={this.onShowOptions} />; return <VariableLink text={linkText} tags={tags} onClick={this.onShowOptions} loading={loading} />;
} }
renderOptions(showOptions: boolean, picker: OptionsPickerState) { renderOptions(showOptions: boolean, picker: OptionsPickerState) {

View File

@ -1,7 +1,7 @@
import { reduxTester } from '../../../../../test/core/redux/reduxTester'; import { reduxTester } from '../../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../../state/helpers'; import { getRootReducer } from '../../state/helpers';
import { TemplatingState } from '../../state/reducers'; import { TemplatingState } from '../../state/reducers';
import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../../types'; import { initialVariableModelState, QueryVariableModel, VariableRefresh, VariableSort } from '../../types';
import { import {
hideOptions, hideOptions,
moveOptionsHighlight, moveOptionsHighlight,
@ -404,17 +404,14 @@ describe('options picker actions', () => {
function createMultiVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel { function createMultiVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel {
return { return {
...initialVariableModelState,
type: 'query', type: 'query',
id: '0', id: '0',
global: false, index: 0,
current: createOption([]), current: createOption([]),
options: [], options: [],
query: 'options-query', query: 'options-query',
name: 'Constant', name: 'Constant',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
datasource: 'datasource', datasource: 'datasource',
definition: '', definition: '',
sort: VariableSort.alphabeticalAsc, sort: VariableSort.alphabeticalAsc,

View File

@ -1,39 +1,59 @@
import React, { PureComponent } from 'react'; import React, { FC, MouseEvent, useCallback } from 'react';
import { getTagColorsFromName, Icon } from '@grafana/ui'; import { css } from 'emotion';
import { getTagColorsFromName, Icon, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { GrafanaTheme } from '@grafana/data';
import { VariableTag } from '../../types'; import { VariableTag } from '../../types';
import { css } from 'emotion';
interface Props { interface Props {
onClick: () => void; onClick: () => void;
text: string; text: string;
tags: VariableTag[]; tags: VariableTag[];
loading: boolean;
} }
export class VariableLink extends PureComponent<Props> {
onClick = (event: React.MouseEvent<HTMLAnchorElement>) => { export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, tags, text }) => {
const styles = useStyles(getStyles);
const onClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
this.props.onClick(); propsOnClick();
}; },
[propsOnClick]
render() { );
const { tags = [], text } = this.props;
if (loading) {
return ( return (
<a <div
onClick={this.onClick} className={styles.container}
className="variable-value-link"
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)} aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
title={text} title={text}
> >
<span <VariableLinkText tags={tags} text={text} />
className={css` <Icon className="spin-clockwise" name="sync" size="xs" />
overflow: hidden; </div>
text-overflow: ellipsis; );
white-space: nowrap; }
`}
return (
<a
onClick={onClick}
className={styles.container}
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
title={text}
> >
<VariableLinkText tags={tags} text={text} />
<Icon name="angle-down" size="sm" />
</a>
);
};
const VariableLinkText: FC<Pick<Props, 'tags' | 'text'>> = ({ tags, text }) => {
const styles = useStyles(getStyles);
return (
<span className={styles.textAndTags}>
{text} {text}
{tags.map(tag => { {tags.map(tag => {
const { color, borderColor } = getTagColorsFromName(tag.text.toString()); const { color, borderColor } = getTagColorsFromName(tag.text.toString());
@ -48,8 +68,31 @@ export class VariableLink extends PureComponent<Props> {
); );
})} })}
</span> </span>
<Icon name="angle-down" size="sm" />
</a>
); );
};
const getStyles = (theme: GrafanaTheme) => ({
container: css`
max-width: 500px;
padding-right: 10px;
padding: 0 ${theme.spacing.sm};
background-color: ${theme.colors.formInputBg};
border: 1px solid ${theme.colors.formInputBorder};
border-radius: ${theme.border.radius.sm};
display: flex;
align-items: center;
color: ${theme.colors.text};
height: ${theme.height.md}px;
.label-tag {
margin: 0 5px;
} }
} `,
textAndTags: css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: ${theme.spacing.xxs};
user-select: none;
`,
});

View File

@ -1,10 +1,19 @@
import { LoadingState } from '@grafana/data';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { createQueryVariableAdapter } from './adapter'; import { createQueryVariableAdapter } from './adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../state/helpers'; import { getRootReducer } from '../state/helpers';
import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../types'; import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, toVariablePayload } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, toVariablePayload } from '../state/types';
import { addVariable, changeVariableProp, setCurrentVariableValue } from '../state/sharedReducer'; import {
addVariable,
changeVariableProp,
setCurrentVariableValue,
variableStateCompleted,
variableStateFailed,
variableStateFetching,
} from '../state/sharedReducer';
import { TemplatingState } from '../state/reducers'; import { TemplatingState } from '../state/reducers';
import { import {
changeQueryVariableDataSource, changeQueryVariableDataSource,
@ -21,6 +30,9 @@ import {
} from '../editor/reducer'; } from '../editor/reducer';
import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor'; import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor';
import { expect } from 'test/lib/common'; import { expect } from 'test/lib/common';
import { updateOptions } from '../state/actions';
import { notifyApp } from '../../../core/reducers/appNotification';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
const mocks: Record<string, any> = { const mocks: Record<string, any> = {
datasource: { datasource: {
@ -215,6 +227,7 @@ describe('query actions', () => {
}); });
describe('when updateQueryVariableOptions is dispatched and fails for variable open in editor', () => { describe('when updateQueryVariableOptions is dispatched and fails for variable open in editor', () => {
silenceConsoleOutput();
it('then correct actions are dispatched', async () => { it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false }); const variable = createVariable({ includeAll: true, useTags: false });
const error = { message: 'failed to fetch metrics' }; const error = { message: 'failed to fetch metrics' };
@ -225,15 +238,23 @@ describe('query actions', () => {
.givenRootReducer(getRootReducer()) .givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(setIdInEditor({ id: variable.id })) .whenActionIsDispatched(setIdInEditor({ id: variable.id }))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); .whenAsyncActionIsDispatched(updateOptions(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => { tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
const [clearErrors, errorOccurred] = actions; const expectedNumberOfActions = 5;
const expectedNumberOfActions = 2;
expect(errorOccurred).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message })); expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(variable)));
expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); expect(dispatchedActions[1]).toEqual(removeVariableEditorError({ errorProp: 'update' }));
return actions.length === expectedNumberOfActions; expect(dispatchedActions[2]).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message }));
expect(dispatchedActions[3]).toEqual(
variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } }))
);
expect(dispatchedActions[4].type).toEqual(notifyApp.type);
expect(dispatchedActions[4].payload.title).toEqual('Templating [0]');
expect(dispatchedActions[4].payload.text).toEqual('Error updating options: failed to fetch metrics');
expect(dispatchedActions[4].payload.severity).toEqual('error');
return dispatchedActions.length === expectedNumberOfActions;
}); });
}); });
}); });
@ -435,23 +456,16 @@ describe('query actions', () => {
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
const update = { results: optionsMetrics, templatedRegex: '' }; const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => { tester.thenDispatchedActionsShouldEqual(
const [clearError, changeQuery, changeDefinition, updateOptions, updateTags, setOption] = actions; removeVariableEditorError({ errorProp: 'query' }),
const expectedNumberOfActions = 6; changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' })); variableStateFetching(toVariablePayload(variable)),
expect(changeQuery).toEqual( updateVariableOptions(toVariablePayload(variable, update)),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })) updateVariableTags(toVariablePayload(variable, tagsMetrics)),
setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
); );
expect(changeDefinition).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition }))
);
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update)));
expect(updateTags).toEqual(updateVariableTags(toVariablePayload(variable, tagsMetrics)));
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
return actions.length === expectedNumberOfActions;
});
}); });
}); });
@ -473,22 +487,15 @@ describe('query actions', () => {
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
const update = { results: optionsMetrics, templatedRegex: '' }; const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => { tester.thenDispatchedActionsShouldEqual(
const [clearError, changeQuery, changeDefinition, updateOptions, setOption] = actions; removeVariableEditorError({ errorProp: 'query' }),
const expectedNumberOfActions = 5; changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' })); variableStateFetching(toVariablePayload(variable)),
expect(changeQuery).toEqual( updateVariableOptions(toVariablePayload(variable, update)),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })) setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
); );
expect(changeDefinition).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition }))
);
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update)));
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
return actions.length === expectedNumberOfActions;
});
}); });
}); });
@ -509,22 +516,15 @@ describe('query actions', () => {
const option = createOption('A'); const option = createOption('A');
const update = { results: optionsMetrics, templatedRegex: '' }; const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => { tester.thenDispatchedActionsShouldEqual(
const [clearError, changeQuery, changeDefinition, updateOptions, setOption] = actions; removeVariableEditorError({ errorProp: 'query' }),
const expectedNumberOfActions = 5; changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' })); variableStateFetching(toVariablePayload(variable)),
expect(changeQuery).toEqual( updateVariableOptions(toVariablePayload(variable, update)),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })) setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
); );
expect(changeDefinition).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition }))
);
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update)));
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
return actions.length === expectedNumberOfActions;
});
}); });
}); });
@ -588,6 +588,8 @@ function createVariable(extend?: Partial<QueryVariableModel>): QueryVariableMode
regex: '', regex: '',
multi: true, multi: true,
includeAll: true, includeAll: true,
state: LoadingState.NotStarted,
error: null,
...(extend ?? {}), ...(extend ?? {}),
}; };
} }

View File

@ -1,16 +1,15 @@
import { AppEvents, DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data'; import { DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { toDataQueryError, getTemplateSrv } from '@grafana/runtime';
import { validateVariableSelectionState } from '../state/actions';
import { updateOptions, validateVariableSelectionState } from '../state/actions';
import { QueryVariableModel, VariableRefresh } from '../types'; import { QueryVariableModel, VariableRefresh } from '../types';
import { ThunkResult } from '../../../types'; import { ThunkResult } from '../../../types';
import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import appEvents from '../../../core/app_events';
import { importDataSourcePlugin } from '../../plugins/plugin_loader'; import { importDataSourcePlugin } from '../../plugins/plugin_loader';
import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor'; import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor';
import { getVariable } from '../state/selectors'; import { getVariable } from '../state/selectors';
import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer'; import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer';
import { variableAdapters } from '../adapters';
import { changeVariableProp } from '../state/sharedReducer'; import { changeVariableProp } from '../state/sharedReducer';
import { updateVariableOptions, updateVariableTags } from './reducer'; import { updateVariableOptions, updateVariableTags } from './reducer';
import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types';
@ -60,17 +59,12 @@ export const updateQueryVariableOptions = (
await dispatch(validateVariableSelectionState(toVariableIdentifier(variableInState))); await dispatch(validateVariableSelectionState(toVariableIdentifier(variableInState)));
} }
} catch (err) { } catch (err) {
console.error(err); const error = toDataQueryError(err);
if (err.data && err.data.message) {
err.message = err.data.message;
}
if (getState().templating.editor.id === variableInState.id) { if (getState().templating.editor.id === variableInState.id) {
dispatch(addVariableEditorError({ errorProp: 'update', errorText: err.message })); dispatch(addVariableEditorError({ errorProp: 'update', errorText: error.message }));
} }
appEvents.emit(AppEvents.alertError, [
'Templating', throw error;
'Template variables could not be initialized: ' + err.message,
]);
} }
}; };
}; };
@ -126,7 +120,7 @@ export const changeQueryVariableQuery = (
dispatch(removeVariableEditorError({ errorProp: 'query' })); dispatch(removeVariableEditorError({ errorProp: 'query' }));
dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })));
dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition }))); dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition })));
await variableAdapters.get(identifier.type).updateOptions(variableInState); await dispatch(updateOptions(identifier));
}; };
const getTemplatedRegex = (variable: QueryVariableModel): string => { const getTemplatedRegex = (variable: QueryVariableModel): string => {

View File

@ -33,7 +33,7 @@ export const createQueryVariableAdapter = (): VariableAdapter<QueryVariableModel
await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter)); await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, queryValue, ...rest } = cloneDeep(variable); const { index, id, state, global, queryValue, ...rest } = cloneDeep(variable);
// remove options // remove options
if (variable.refresh !== VariableRefresh.never) { if (variable.refresh !== VariableRefresh.never) {
return { ...rest, options: [] }; return { ...rest, options: [] };

View File

@ -2,13 +2,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import _ from 'lodash'; import _ from 'lodash';
import { DataSourceApi, DataSourceSelectItem, MetricFindValue, stringToJsRegex } from '@grafana/data'; import { DataSourceApi, DataSourceSelectItem, MetricFindValue, stringToJsRegex } from '@grafana/data';
import { QueryVariableModel, VariableHide, VariableOption, VariableRefresh, VariableSort, VariableTag } from '../types'; import {
initialVariableModelState,
QueryVariableModel,
VariableOption,
VariableRefresh,
VariableSort,
VariableTag,
} from '../types';
import { import {
ALL_VARIABLE_TEXT, ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE, ALL_VARIABLE_VALUE,
getInstanceState, getInstanceState,
NEW_VARIABLE_ID,
NONE_VARIABLE_TEXT, NONE_VARIABLE_TEXT,
NONE_VARIABLE_VALUE, NONE_VARIABLE_VALUE,
VariablePayload, VariablePayload,
@ -29,14 +35,8 @@ export interface QueryVariableEditorState {
} }
export const initialQueryVariableModelState: QueryVariableModel = { export const initialQueryVariableModelState: QueryVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
index: -1,
type: 'query', type: 'query',
name: '',
label: null,
hide: VariableHide.dontHide,
skipUrlSync: false,
datasource: null, datasource: null,
query: '', query: '',
regex: '', regex: '',
@ -52,7 +52,6 @@ export const initialQueryVariableModelState: QueryVariableModel = {
tagsQuery: '', tagsQuery: '',
tagValuesQuery: '', tagValuesQuery: '',
definition: '', definition: '',
initLock: null,
}; };
const sortVariableValues = (options: any[], sortOrder: VariableSort) => { const sortVariableValues = (options: any[], sortOrder: VariableSort) => {

View File

@ -20,13 +20,12 @@ import {
validateVariableSelectionState, validateVariableSelectionState,
} from './actions'; } from './actions';
import { import {
addInitLock,
addVariable, addVariable,
changeVariableProp, changeVariableProp,
removeInitLock,
removeVariable, removeVariable,
resolveInitLock,
setCurrentVariableValue, setCurrentVariableValue,
variableStateCompleted,
variableStateNotStarted,
} from './sharedReducer'; } from './sharedReducer';
import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload } from './types'; import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload } from './types';
import { import {
@ -98,16 +97,16 @@ describe('shared actions', () => {
// because uuid are dynamic we need to get the uuid from the resulting state // 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 // an alternative would be to add our own uuids in the model above instead
expect(dispatchedActions[4]).toEqual( expect(dispatchedActions[4]).toEqual(
addInitLock(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) variableStateNotStarted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id }))
); );
expect(dispatchedActions[5]).toEqual( expect(dispatchedActions[5]).toEqual(
addInitLock(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id })) variableStateNotStarted(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id }))
); );
expect(dispatchedActions[6]).toEqual( expect(dispatchedActions[6]).toEqual(
addInitLock(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id })) variableStateNotStarted(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id }))
); );
expect(dispatchedActions[7]).toEqual( expect(dispatchedActions[7]).toEqual(
addInitLock(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id })) variableStateNotStarted(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id }))
); );
return true; return true;
@ -128,36 +127,27 @@ describe('shared actions', () => {
preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query: {} } }, preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query: {} } },
}) })
.givenRootReducer(getTemplatingAndLocationRootReducer()) .givenRootReducer(getTemplatingAndLocationRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariables(), true); .whenAsyncActionIsDispatched(processVariables(), true);
await tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => { await tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
expect(dispatchedActions.length).toEqual(8); expect(dispatchedActions.length).toEqual(4);
expect(dispatchedActions[0]).toEqual( expect(dispatchedActions[0]).toEqual(
resolveInitLock(toVariablePayload({ ...query, id: dispatchedActions[0].payload.id })) variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[0].payload.id }))
);
expect(dispatchedActions[1]).toEqual(
resolveInitLock(toVariablePayload({ ...constant, id: dispatchedActions[1].payload.id }))
);
expect(dispatchedActions[2]).toEqual(
resolveInitLock(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id }))
);
expect(dispatchedActions[3]).toEqual(
resolveInitLock(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id }))
); );
expect(dispatchedActions[4]).toEqual( expect(dispatchedActions[1]).toEqual(
removeInitLock(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) variableStateCompleted(toVariablePayload({ ...constant, id: dispatchedActions[1].payload.id }))
); );
expect(dispatchedActions[5]).toEqual(
removeInitLock(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id })) expect(dispatchedActions[2]).toEqual(
variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id }))
); );
expect(dispatchedActions[6]).toEqual(
removeInitLock(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id })) expect(dispatchedActions[3]).toEqual(
); variableStateCompleted(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id }))
expect(dispatchedActions[7]).toEqual(
removeInitLock(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id }))
); );
return true; return true;
@ -578,12 +568,11 @@ describe('shared actions', () => {
expect(dispatchedActions[4]).toEqual( expect(dispatchedActions[4]).toEqual(
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
); );
expect(dispatchedActions[5]).toEqual(addInitLock(toVariablePayload(constant))); expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant)));
expect(dispatchedActions[6]).toEqual(resolveInitLock(toVariablePayload(constant))); expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant)));
expect(dispatchedActions[7]).toEqual(removeInitLock(toVariablePayload(constant)));
expect(dispatchedActions[8]).toEqual(variablesCompleteTransaction({ uid })); expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid }));
return dispatchedActions.length === 9; return dispatchedActions.length === 8;
}); });
}); });
}); });
@ -618,11 +607,10 @@ describe('shared actions', () => {
expect(dispatchedActions[6]).toEqual( expect(dispatchedActions[6]).toEqual(
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
); );
expect(dispatchedActions[7]).toEqual(addInitLock(toVariablePayload(constant))); expect(dispatchedActions[7]).toEqual(variableStateNotStarted(toVariablePayload(constant)));
expect(dispatchedActions[8]).toEqual(resolveInitLock(toVariablePayload(constant))); expect(dispatchedActions[8]).toEqual(variableStateCompleted(toVariablePayload(constant)));
expect(dispatchedActions[9]).toEqual(removeInitLock(toVariablePayload(constant))); expect(dispatchedActions[9]).toEqual(variablesCompleteTransaction({ uid }));
expect(dispatchedActions[10]).toEqual(variablesCompleteTransaction({ uid })); return dispatchedActions.length === 10;
return dispatchedActions.length === 11;
}); });
}); });
}); });

View File

@ -1,9 +1,10 @@
import castArray from 'lodash/castArray';
import { AppEvents, TimeRange, UrlQueryMap, UrlQueryValue } from '@grafana/data';
import angular from 'angular'; import angular from 'angular';
import castArray from 'lodash/castArray';
import { LoadingState, TimeRange, UrlQueryMap, UrlQueryValue } from '@grafana/data';
import { import {
DashboardVariableModel, DashboardVariableModel,
initialVariableModelState,
OrgVariableModel, OrgVariableModel,
QueryVariableModel, QueryVariableModel,
UserVariableModel, UserVariableModel,
@ -14,21 +15,21 @@ import {
VariableWithMultiSupport, VariableWithMultiSupport,
VariableWithOptions, VariableWithOptions,
} from '../types'; } from '../types';
import { StoreState, ThunkResult } from '../../../types'; import { AppNotification, StoreState, ThunkResult } from '../../../types';
import { getVariable, getVariables } from './selectors'; import { getVariable, getVariables } from './selectors';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { Graph } from '../../../core/utils/dag'; import { Graph } from '../../../core/utils/dag';
import { notifyApp, updateLocation } from 'app/core/actions'; import { notifyApp, updateLocation } from 'app/core/actions';
import { import {
addInitLock,
addVariable, addVariable,
changeVariableProp, changeVariableProp,
removeInitLock,
resolveInitLock,
setCurrentVariableValue, setCurrentVariableValue,
variableStateCompleted,
variableStateFailed,
variableStateFetching,
variableStateNotStarted,
} from './sharedReducer'; } from './sharedReducer';
import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types'; import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types';
import { appEvents } from 'app/core/core';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv';
import { alignCurrentWithMulti } from '../shared/multiOptions'; import { alignCurrentWithMulti } from '../shared/multiOptions';
@ -46,6 +47,7 @@ import { getBackendSrv } from '../../../core/services/backend_srv';
import { cleanVariables } from './variablesReducer'; import { cleanVariables } from './variablesReducer';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { getCurrentText } from '../utils'; import { getCurrentText } from '../utils';
import { store } from 'app/store/store';
// process flow queryVariable // process flow queryVariable
// thunk => processVariables // thunk => processVariables
@ -92,7 +94,7 @@ export const initDashboardTemplating = (list: VariableModel[]): ThunkResult<void
getTemplateSrv().updateTimeRange(getTimeSrv().timeRange()); getTemplateSrv().updateTimeRange(getTimeSrv().timeRange());
for (let index = 0; index < getVariables(getState()).length; index++) { for (let index = 0; index < getVariables(getState()).length; index++) {
dispatch(addInitLock(toVariablePayload(getVariables(getState())[index]))); dispatch(variableStateNotStarted(toVariablePayload(getVariables(getState())[index])));
} }
}; };
}; };
@ -100,14 +102,13 @@ export const initDashboardTemplating = (list: VariableModel[]): ThunkResult<void
export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult<void> => { export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult<void> => {
return (dispatch, getState) => { return (dispatch, getState) => {
const dashboardModel: DashboardVariableModel = { const dashboardModel: DashboardVariableModel = {
...initialVariableModelState,
id: '__dashboard', id: '__dashboard',
name: '__dashboard', name: '__dashboard',
label: null,
type: 'system', type: 'system',
index: -3, index: -3,
skipUrlSync: true, skipUrlSync: true,
hide: VariableHide.hideVariable, hide: VariableHide.hideVariable,
global: false,
current: { current: {
value: { value: {
name: dashboard.title, name: dashboard.title,
@ -128,14 +129,13 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu
); );
const orgModel: OrgVariableModel = { const orgModel: OrgVariableModel = {
...initialVariableModelState,
id: '__org', id: '__org',
name: '__org', name: '__org',
label: null,
type: 'system', type: 'system',
index: -2, index: -2,
skipUrlSync: true, skipUrlSync: true,
hide: VariableHide.hideVariable, hide: VariableHide.hideVariable,
global: false,
current: { current: {
value: { value: {
name: contextSrv.user.orgName, name: contextSrv.user.orgName,
@ -150,14 +150,13 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu
); );
const userModel: UserVariableModel = { const userModel: UserVariableModel = {
...initialVariableModelState,
id: '__user', id: '__user',
name: '__user', name: '__user',
label: null,
type: 'system', type: 'system',
index: -1, index: -1,
skipUrlSync: true, skipUrlSync: true,
hide: VariableHide.hideVariable, hide: VariableHide.hideVariable,
global: false,
current: { current: {
value: { value: {
login: contextSrv.user.login, login: contextSrv.user.login,
@ -184,7 +183,7 @@ export const changeVariableMultiValue = (identifier: VariableIdentifier, multi:
}; };
export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => { export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => {
let dependencies: Array<Promise<any>> = []; const dependencies: VariableModel[] = [];
for (const otherVariable of getVariables(state)) { for (const otherVariable of getVariables(state)) {
if (variable === otherVariable) { if (variable === otherVariable) {
@ -193,12 +192,36 @@ export const processVariableDependencies = async (variable: VariableModel, state
if (variableAdapters.getIfExists(variable.type)) { if (variableAdapters.getIfExists(variable.type)) {
if (variableAdapters.get(variable.type).dependsOn(variable, otherVariable)) { if (variableAdapters.get(variable.type).dependsOn(variable, otherVariable)) {
dependencies.push(otherVariable.initLock!.promise); dependencies.push(otherVariable);
} }
} }
} }
await Promise.all(dependencies); if (!isWaitingForDependencies(dependencies, state)) {
return;
}
await new Promise(resolve => {
const unsubscribe = store.subscribe(() => {
if (!isWaitingForDependencies(dependencies, store.getState())) {
unsubscribe();
resolve();
}
});
});
};
const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreState): boolean => {
if (dependencies.length === 0) {
return false;
}
const variables = getVariables(state);
const notCompletedDependencies = dependencies.filter(d =>
variables.some(v => v.id === d.id && (v.state === LoadingState.NotStarted || v.state === LoadingState.Loading))
);
return notCompletedDependencies.length > 0;
}; };
export const processVariable = ( export const processVariable = (
@ -212,7 +235,6 @@ export const processVariable = (
const urlValue = queryParams['var-' + variable.name]; const urlValue = queryParams['var-' + variable.name];
if (urlValue !== void 0) { if (urlValue !== void 0) {
await variableAdapters.get(variable.type).setValueFromUrl(variable, urlValue ?? ''); await variableAdapters.get(variable.type).setValueFromUrl(variable, urlValue ?? '');
dispatch(resolveInitLock(toVariablePayload(variable)));
return; return;
} }
@ -222,13 +244,13 @@ export const processVariable = (
refreshableVariable.refresh === VariableRefresh.onDashboardLoad || refreshableVariable.refresh === VariableRefresh.onDashboardLoad ||
refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged
) { ) {
await variableAdapters.get(variable.type).updateOptions(refreshableVariable); await dispatch(updateOptions(toVariableIdentifier(refreshableVariable)));
dispatch(resolveInitLock(toVariablePayload(variable)));
return; return;
} }
} }
dispatch(resolveInitLock(toVariablePayload(variable))); // for variables that aren't updated via url or refresh let's simulate the same state changes
dispatch(variableStateCompleted(toVariablePayload(variable)));
}; };
}; };
@ -240,10 +262,6 @@ export const processVariables = (): ThunkResult<Promise<void>> => {
); );
await Promise.all(promises); await Promise.all(promises);
for (let index = 0; index < getVariables(getState()).length; index++) {
dispatch(removeInitLock(toVariablePayload(getVariables(getState())[index])));
}
}; };
}; };
@ -255,7 +273,12 @@ export const setOptionFromUrl = (
const variable = getVariable(identifier.id, getState()); const variable = getVariable(identifier.id, getState());
if (variable.hasOwnProperty('refresh') && (variable as QueryVariableModel).refresh !== VariableRefresh.never) { if (variable.hasOwnProperty('refresh') && (variable as QueryVariableModel).refresh !== VariableRefresh.never) {
// updates options // updates options
await variableAdapters.get(variable.type).updateOptions(variable); await dispatch(updateOptions(toVariableIdentifier(variable)));
}
if (variable.hasOwnProperty('refresh') && (variable as QueryVariableModel).refresh === VariableRefresh.never) {
// for variables that have refresh to never simulate the same state changes
dispatch(variableStateCompleted(toVariablePayload(variable)));
} }
// get variable from state // get variable from state
@ -425,16 +448,17 @@ export const variableUpdated = (
emitChangeEvents: boolean emitChangeEvents: boolean
): ThunkResult<Promise<void>> => { ): ThunkResult<Promise<void>> => {
return (dispatch, getState) => { return (dispatch, getState) => {
// if there is a variable lock ignore cascading update because we are in a boot up scenario const variableInState = getVariable(identifier.id, getState());
const variable = getVariable(identifier.id, getState());
if (variable.initLock) { // if we're initializing variables ignore cascading update because we are in a boot up scenario
if (getState().templating.transaction.status === TransactionStatus.Fetching) {
return Promise.resolve(); return Promise.resolve();
} }
const variables = getVariables(getState()); const variables = getVariables(getState());
const g = createGraph(variables); const g = createGraph(variables);
const node = g.getNode(variable.name); const node = g.getNode(variableInState.name);
let promises: Array<Promise<any>> = []; let promises: Array<Promise<any>> = [];
if (node) { if (node) {
promises = node.getOptimizedInputEdges().map(e => { promises = node.getOptimizedInputEdges().map(e => {
@ -442,7 +466,8 @@ export const variableUpdated = (
if (!variable) { if (!variable) {
return Promise.resolve(); return Promise.resolve();
} }
return variableAdapters.get(variable.type).updateOptions(variable);
return dispatch(updateOptions(toVariableIdentifier(variable)));
}); });
} }
@ -459,12 +484,11 @@ export const variableUpdated = (
export interface OnTimeRangeUpdatedDependencies { export interface OnTimeRangeUpdatedDependencies {
templateSrv: TemplateSrv; templateSrv: TemplateSrv;
appEvents: typeof appEvents;
} }
export const onTimeRangeUpdated = ( export const onTimeRangeUpdated = (
timeRange: TimeRange, timeRange: TimeRange,
dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), appEvents: appEvents } dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv() }
): ThunkResult<Promise<void>> => async (dispatch, getState) => { ): ThunkResult<Promise<void>> => async (dispatch, getState) => {
dependencies.templateSrv.updateTimeRange(timeRange); dependencies.templateSrv.updateTimeRange(timeRange);
const variablesThatNeedRefresh = getVariables(getState()).filter(variable => { const variablesThatNeedRefresh = getVariables(getState()).filter(variable => {
@ -476,15 +500,9 @@ export const onTimeRangeUpdated = (
return false; return false;
}); });
const promises = variablesThatNeedRefresh.map(async (variable: VariableWithOptions) => { const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) =>
const previousOptions = variable.options.slice(); dispatch(timeRangeUpdated(toVariableIdentifier(variable)))
await variableAdapters.get(variable.type).updateOptions(variable); );
const updatedVariable = getVariable<VariableWithOptions>(variable.id, getState());
if (angular.toJson(previousOptions) !== angular.toJson(updatedVariable.options)) {
const dashboard = getState().dashboard.getModel();
dashboard?.templateVariableValueUpdated();
}
});
try { try {
await Promise.all(promises); await Promise.all(promises);
@ -492,7 +510,22 @@ export const onTimeRangeUpdated = (
dashboard?.startRefresh(); dashboard?.startRefresh();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
dependencies.appEvents.emit(AppEvents.alertError, ['Template variable service failed', error.message]); dispatch(notifyApp(createVariableErrorNotification('Template variable service failed', error)));
}
};
const timeRangeUpdated = (identifier: VariableIdentifier): ThunkResult<Promise<void>> => async (dispatch, getState) => {
const variableInState = getVariable<VariableWithOptions>(identifier.id);
const previousOptions = variableInState.options.slice();
await dispatch(updateOptions(toVariableIdentifier(variableInState), true));
const updatedVariable = getVariable<VariableWithOptions>(identifier.id, getState());
const updatedOptions = updatedVariable.options;
if (angular.toJson(previousOptions) !== angular.toJson(updatedOptions)) {
const dashboard = getState().dashboard.getModel();
dashboard?.templateVariableValueUpdated();
} }
}; };
@ -565,7 +598,7 @@ export const initVariablesTransaction = (dashboardUid: string, dashboard: Dashbo
// Mark update as complete // Mark update as complete
dispatch(variablesCompleteTransaction({ uid: dashboardUid })); dispatch(variablesCompleteTransaction({ uid: dashboardUid }));
} catch (err) { } catch (err) {
dispatch(notifyApp(createErrorNotification('Templating init failed', err))); dispatch(notifyApp(createVariableErrorNotification('Templating init failed', err)));
console.error(err); console.error(err);
} }
}; };
@ -582,3 +615,36 @@ export const cancelVariables = (
dependencies.getBackendSrv().cancelAllInFlightRequests(); dependencies.getBackendSrv().cancelAllInFlightRequests();
dispatch(cleanUpVariables()); dispatch(cleanUpVariables());
}; };
export const updateOptions = (identifier: VariableIdentifier, rethrow = false): ThunkResult<Promise<void>> => async (
dispatch,
getState
) => {
const variableInState = getVariable(identifier.id, getState());
try {
dispatch(variableStateFetching(toVariablePayload(variableInState)));
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
dispatch(variableStateCompleted(toVariablePayload(variableInState)));
} catch (error) {
dispatch(variableStateFailed(toVariablePayload(variableInState, { error })));
if (!rethrow) {
console.error(error);
dispatch(notifyApp(createVariableErrorNotification('Error updating options:', error, identifier)));
}
if (rethrow) {
throw error;
}
}
};
export const createVariableErrorNotification = (
message: string,
error: any,
identifier?: VariableIdentifier
): AppNotification =>
createErrorNotification(
`${identifier ? `Templating [${identifier.id}]` : 'Templating'}`,
`${message} ${error.message}`
);

View File

@ -1,4 +1,5 @@
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import { LoadingState } from '@grafana/data';
import { NEW_VARIABLE_ID } from './types'; import { NEW_VARIABLE_ID } from './types';
import { VariableHide, VariableModel } from '../types'; import { VariableHide, VariableModel } from '../types';
@ -25,6 +26,8 @@ export const getVariableState = (
label: `Label-${index}`, label: `Label-${index}`,
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}; };
} }
@ -38,6 +41,8 @@ export const getVariableState = (
label: `Label-${NEW_VARIABLE_ID}`, label: `Label-${NEW_VARIABLE_ID}`,
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}; };
} }

View File

@ -1,8 +1,7 @@
import { dateTime, TimeRange } from '@grafana/data'; import { dateTime, TimeRange } from '@grafana/data';
import { TemplateSrv } from '../../templating/template_srv'; import { TemplateSrv } from '../../templating/template_srv';
import { Emitter } from '../../../core/utils/emitter'; import { onTimeRangeUpdated, OnTimeRangeUpdatedDependencies, setOptionAsCurrent } from './actions';
import { onTimeRangeUpdated, OnTimeRangeUpdatedDependencies } from './actions';
import { DashboardModel } from '../../dashboard/state'; import { DashboardModel } from '../../dashboard/state';
import { DashboardState } from '../../../types'; import { DashboardState } from '../../../types';
import { createIntervalVariableAdapter } from '../interval/adapter'; import { createIntervalVariableAdapter } from '../interval/adapter';
@ -10,10 +9,39 @@ import { variableAdapters } from '../adapters';
import { createConstantVariableAdapter } from '../constant/adapter'; import { createConstantVariableAdapter } from '../constant/adapter';
import { VariableRefresh } from '../types'; import { VariableRefresh } from '../types';
import { constantBuilder, intervalBuilder } from '../shared/testing/builders'; import { constantBuilder, intervalBuilder } from '../shared/testing/builders';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from './reducers';
import { getRootReducer } from './helpers';
import { toVariableIdentifier, toVariablePayload } from './types';
import {
setCurrentVariableValue,
variableStateCompleted,
variableStateFailed,
variableStateFetching,
} from './sharedReducer';
import { createIntervalOptions } from '../interval/reducer';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
import { notifyApp } from '../../../core/reducers/appNotification';
import { expect } from '../../../../test/lib/common';
variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]);
const getOnTimeRangeUpdatedContext = (args: { update?: boolean; throw?: boolean }) => { const getTestContext = () => {
const interval = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const constant = constantBuilder()
.withId('constant-1')
.withName('constant-1')
.withOptions('a constant')
.withCurrent('a constant')
.build();
const range: TimeRange = { const range: TimeRange = {
from: dateTime(new Date().getTime()).subtract(1, 'minutes'), from: dateTime(new Date().getTime()).subtract(1, 'minutes'),
to: dateTime(new Date().getTime()), to: dateTime(new Date().getTime()),
@ -24,9 +52,7 @@ const getOnTimeRangeUpdatedContext = (args: { update?: boolean; throw?: boolean
}; };
const updateTimeRangeMock = jest.fn(); const updateTimeRangeMock = jest.fn();
const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv; const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv;
const emitMock = jest.fn(); const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock };
const appEventsMock = ({ emit: emitMock } as unknown) as Emitter;
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, appEvents: appEventsMock };
const templateVariableValueUpdatedMock = jest.fn(); const templateVariableValueUpdatedMock = jest.fn();
const dashboard = ({ const dashboard = ({
getModel: () => getModel: () =>
@ -37,131 +63,139 @@ const getOnTimeRangeUpdatedContext = (args: { update?: boolean; throw?: boolean
} as unknown) as DashboardState; } as unknown) as DashboardState;
const startRefreshMock = jest.fn(); const startRefreshMock = jest.fn();
const adapter = variableAdapters.get('interval'); const adapter = variableAdapters.get('interval');
adapter.updateOptions = args.throw ? jest.fn().mockRejectedValue('Something broke') : jest.fn().mockResolvedValue({}); const preloadedState = {
// initial variable state
const initialVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
// the constant variable should be filtered out
const constant = constantBuilder()
.withId('constant-1')
.withName('constant-1')
.withOptions('a constant')
.withCurrent('a constant')
.build();
const initialState = {
templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } },
dashboard, dashboard,
location: { query: '' },
templating: ({
variables: {
'interval-0': { ...interval },
'constant-1': { ...constant },
},
} as unknown) as TemplatingState,
}; };
// updated variable state
const updatedVariable = intervalBuilder()
.withId('interval-0')
.withName('interval-0')
.withOptions('1m')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
const state = { templating: { variables: { 'interval-0': variable, 'constant-1': { ...constant } } }, dashboard };
const getStateMock = jest
.fn()
.mockReturnValueOnce(initialState)
.mockReturnValue(state);
const dispatchMock = jest.fn();
return { return {
interval,
range, range,
dependencies, dependencies,
dispatchMock, adapter,
getStateMock, preloadedState,
updateTimeRangeMock, updateTimeRangeMock,
templateVariableValueUpdatedMock, templateVariableValueUpdatedMock,
startRefreshMock, startRefreshMock,
emitMock,
}; };
}; };
describe('when onTimeRangeUpdated is dispatched', () => { describe('when onTimeRangeUpdated is dispatched', () => {
describe('and options are changed by update', () => { describe('and options are changed by update', () => {
it('then correct dependencies are called', async () => { it('then correct actions are dispatched and correct dependencies are called', async () => {
const { const {
preloadedState,
range, range,
dependencies, dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock, updateTimeRangeMock,
templateVariableValueUpdatedMock, templateVariableValueUpdatedMock,
startRefreshMock, startRefreshMock,
emitMock, } = getTestContext();
} = getOnTimeRangeUpdatedContext({ update: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined); const tester = await reduxTester<{ templating: TemplatingState }>({ preloadedState })
.givenRootReducer(getRootReducer())
.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies));
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 } }
)
),
variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))
);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(4);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range); expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1); expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1);
expect(startRefreshMock).toHaveBeenCalledTimes(1); expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
}); });
}); });
describe('and options are not changed by update', () => { describe('and options are not changed by update', () => {
it('then correct dependencies are called', async () => { it('then correct actions are dispatched and correct dependencies are called', async () => {
const { const {
interval,
preloadedState,
range, range,
dependencies, dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock, updateTimeRangeMock,
templateVariableValueUpdatedMock, templateVariableValueUpdatedMock,
startRefreshMock, startRefreshMock,
emitMock, } = getTestContext();
} = getOnTimeRangeUpdatedContext({ update: false });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined); const tester = await reduxTester<{ templating: TemplatingState }>({ preloadedState })
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false))
.whenAsyncActionIsDispatched(onTimeRangeUpdated(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 } }
)
),
variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))
);
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(3);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range); expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0); expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(1); expect(startRefreshMock).toHaveBeenCalledTimes(1);
expect(emitMock).toHaveBeenCalledTimes(0);
}); });
}); });
describe('and updateOptions throws', () => { describe('and updateOptions throws', () => {
it('then correct dependencies are called', async () => { silenceConsoleOutput();
it('then correct actions are dispatched and correct dependencies are called', async () => {
const { const {
adapter,
preloadedState,
range, range,
dependencies, dependencies,
dispatchMock,
getStateMock,
updateTimeRangeMock, updateTimeRangeMock,
templateVariableValueUpdatedMock, templateVariableValueUpdatedMock,
startRefreshMock, startRefreshMock,
emitMock, } = getTestContext();
} = getOnTimeRangeUpdatedContext({ update: false, throw: true });
await onTimeRangeUpdated(range, dependencies)(dispatchMock, getStateMock, undefined); adapter.updateOptions = jest.fn().mockRejectedValue(new Error('Something broke'));
const tester = await reduxTester<{ templating: TemplatingState }>({ preloadedState, debug: true })
.givenRootReducer(getRootReducer())
.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true);
tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
expect(dispatchedActions[0]).toEqual(
variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))
);
expect(dispatchedActions[1]).toEqual(
variableStateFailed(
toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') })
)
);
expect(dispatchedActions[2].type).toEqual(notifyApp.type);
expect(dispatchedActions[2].payload.title).toEqual('Templating');
expect(dispatchedActions[2].payload.text).toEqual('Template variable service failed Something broke');
expect(dispatchedActions[2].payload.severity).toEqual('error');
return dispatchedActions.length === 3;
});
expect(dispatchMock).toHaveBeenCalledTimes(0);
expect(getStateMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1);
expect(updateTimeRangeMock).toHaveBeenCalledWith(range); expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0); expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
expect(startRefreshMock).toHaveBeenCalledTimes(0); expect(startRefreshMock).toHaveBeenCalledTimes(0);
expect(emitMock).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

View File

@ -7,11 +7,12 @@ import { createCustomVariableAdapter } from '../custom/adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from 'app/features/variables/state/reducers'; import { TemplatingState } from 'app/features/variables/state/reducers';
import { initDashboardTemplating, processVariable } from './actions'; import { initDashboardTemplating, processVariable } from './actions';
import { resolveInitLock, setCurrentVariableValue } from './sharedReducer'; import { setCurrentVariableValue, variableStateCompleted, variableStateFetching } from './sharedReducer';
import { toVariableIdentifier, toVariablePayload } from './types'; import { toVariableIdentifier, toVariablePayload } from './types';
import { VariableRefresh } from '../types'; import { VariableRefresh } from '../types';
import { updateVariableOptions } from '../query/reducer'; import { updateVariableOptions } from '../query/reducer';
import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { customBuilder, queryBuilder } from '../shared/testing/builders';
import { variablesInitTransaction } from './transactionReducer';
jest.mock('app/features/dashboard/services/TimeSrv', () => ({ jest.mock('app/features/dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({ getTimeSrv: jest.fn().mockReturnValue({
@ -110,12 +111,11 @@ describe('processVariable', () => {
const queryParams: UrlQueryMap = {}; const queryParams: UrlQueryMap = {};
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(custom)));
resolveInitLock(toVariablePayload({ type: 'custom', id: 'custom' }))
);
}); });
}); });
@ -125,14 +125,14 @@ describe('processVariable', () => {
const queryParams: UrlQueryMap = { 'var-custom': 'B' }; const queryParams: UrlQueryMap = { 'var-custom': 'B' };
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
setCurrentVariableValue( setCurrentVariableValue(
toVariablePayload({ type: 'custom', id: 'custom' }, { option: { text: 'B', value: 'B', selected: false } }) toVariablePayload({ type: 'custom', id: 'custom' }, { option: { text: 'B', value: 'B', selected: false } })
), )
resolveInitLock(toVariablePayload({ type: 'custom', id: 'custom' }))
); );
}); });
}); });
@ -150,26 +150,29 @@ describe('processVariable', () => {
queryNoDepends.refresh = refresh; queryNoDepends.refresh = refresh;
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(queryNoDepends)));
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))
);
}); });
}); });
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => { it.each`
describe(`and refresh is ${refresh}`, () => { refresh
it('then correct actions are dispatched', async () => { ${VariableRefresh.onDashboardLoad}
${VariableRefresh.onTimeRangeChanged}
`('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => {
const { list, queryNoDepends } = getAndSetupProcessVariableContext(); const { list, queryNoDepends } = getAndSetupProcessVariableContext();
queryNoDepends.refresh = refresh; queryNoDepends.refresh = refresh;
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })),
updateVariableOptions( updateVariableOptions(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryNoDepends' }, { type: 'query', id: 'queryNoDepends' },
@ -189,12 +192,10 @@ describe('processVariable', () => {
{ option: { text: 'A', value: 'A', selected: false } } { option: { text: 'A', value: 'A', selected: false } }
) )
), ),
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))
); );
}); });
}); });
});
});
describe('and queryParams does match variable', () => { describe('and queryParams does match variable', () => {
const queryParams: UrlQueryMap = { 'var-queryNoDepends': 'B' }; const queryParams: UrlQueryMap = { 'var-queryNoDepends': 'B' };
@ -206,36 +207,37 @@ describe('processVariable', () => {
queryNoDepends.refresh = refresh; queryNoDepends.refresh = refresh;
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })),
setCurrentVariableValue( setCurrentVariableValue(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryNoDepends' }, { type: 'query', id: 'queryNoDepends' },
{ option: { text: 'B', value: 'B', selected: false } } { option: { text: 'B', value: 'B', selected: false } }
) )
), )
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))
); );
}); });
}); });
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => { it.each`
describe(`and refresh is ${ refresh
refresh === VariableRefresh.onDashboardLoad ${VariableRefresh.onDashboardLoad}
? 'VariableRefresh.onDashboardLoad' ${VariableRefresh.onTimeRangeChanged}
: 'VariableRefresh.onTimeRangeChanged' `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => {
}`, () => {
it('then correct actions are dispatched', async () => {
const { list, queryNoDepends } = getAndSetupProcessVariableContext(); const { list, queryNoDepends } = getAndSetupProcessVariableContext();
queryNoDepends.refresh = refresh; queryNoDepends.refresh = refresh;
const tester = await reduxTester<{ templating: TemplatingState }>() const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })),
updateVariableOptions( updateVariableOptions(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryNoDepends' }, { type: 'query', id: 'queryNoDepends' },
@ -255,19 +257,17 @@ describe('processVariable', () => {
{ option: { text: 'A', value: 'A', selected: false } } { option: { text: 'A', value: 'A', selected: false } }
) )
), ),
variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })),
setCurrentVariableValue( setCurrentVariableValue(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryNoDepends' }, { type: 'query', id: 'queryNoDepends' },
{ option: { text: 'B', value: 'B', selected: false } } { option: { text: 'B', value: 'B', selected: false } }
) )
), )
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))
); );
}); });
}); });
}); });
});
});
// testing processVariable for the queryDependsOnCustom variable from case described above // testing processVariable for the queryDependsOnCustom variable from case described above
describe('when processVariable is dispatched for a query variable with one dependency', () => { describe('when processVariable is dispatched for a query variable with one dependency', () => {
@ -281,6 +281,7 @@ describe('processVariable', () => {
queryDependsOnCustom.refresh = refresh; queryDependsOnCustom.refresh = refresh;
const customProcessed = await reduxTester<{ templating: TemplatingState }>() const customProcessed = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
@ -290,18 +291,21 @@ describe('processVariable', () => {
); );
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))
); );
}); });
}); });
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => { it.each`
describe(`and refresh is ${refresh}`, () => { refresh
it('then correct actions are dispatched', async () => { ${VariableRefresh.onDashboardLoad}
${VariableRefresh.onTimeRangeChanged}
`('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => {
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
queryDependsOnCustom.refresh = refresh; queryDependsOnCustom.refresh = refresh;
const customProcessed = await reduxTester<{ templating: TemplatingState }>() const customProcessed = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
@ -311,6 +315,7 @@ describe('processVariable', () => {
); );
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })),
updateVariableOptions( updateVariableOptions(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryDependsOnCustom' }, { type: 'query', id: 'queryDependsOnCustom' },
@ -330,12 +335,10 @@ describe('processVariable', () => {
{ option: { text: 'AA', value: 'AA', selected: false } } { option: { text: 'AA', value: 'AA', selected: false } }
) )
), ),
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))
); );
}); });
}); });
});
});
describe('and queryParams does match variable', () => { describe('and queryParams does match variable', () => {
const queryParams: UrlQueryMap = { 'var-queryDependsOnCustom': 'AB' }; const queryParams: UrlQueryMap = { 'var-queryDependsOnCustom': 'AB' };
@ -347,6 +350,7 @@ describe('processVariable', () => {
queryDependsOnCustom.refresh = refresh; queryDependsOnCustom.refresh = refresh;
const customProcessed = await reduxTester<{ templating: TemplatingState }>() const customProcessed = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
@ -356,28 +360,27 @@ describe('processVariable', () => {
); );
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })),
setCurrentVariableValue( setCurrentVariableValue(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryDependsOnCustom' }, { type: 'query', id: 'queryDependsOnCustom' },
{ option: { text: 'AB', value: 'AB', selected: false } } { option: { text: 'AB', value: 'AB', selected: false } }
) )
), )
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))
); );
}); });
}); });
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => { it.each`
describe(`and refresh is ${ refresh
refresh === VariableRefresh.onDashboardLoad ${VariableRefresh.onDashboardLoad}
? 'VariableRefresh.onDashboardLoad' ${VariableRefresh.onTimeRangeChanged}
: 'VariableRefresh.onTimeRangeChanged' `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => {
}`, () => {
it('then correct actions are dispatched', async () => {
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
queryDependsOnCustom.refresh = refresh; queryDependsOnCustom.refresh = refresh;
const customProcessed = await reduxTester<{ templating: TemplatingState }>() const customProcessed = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer()) .givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list)) .whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
@ -387,6 +390,7 @@ describe('processVariable', () => {
); );
await tester.thenDispatchedActionsShouldEqual( await tester.thenDispatchedActionsShouldEqual(
variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })),
updateVariableOptions( updateVariableOptions(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryDependsOnCustom' }, { type: 'query', id: 'queryDependsOnCustom' },
@ -406,17 +410,15 @@ describe('processVariable', () => {
{ option: { text: 'AA', value: 'AA', selected: false } } { option: { text: 'AA', value: 'AA', selected: false } }
) )
), ),
variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })),
setCurrentVariableValue( setCurrentVariableValue(
toVariablePayload( toVariablePayload(
{ type: 'query', id: 'queryDependsOnCustom' }, { type: 'query', id: 'queryDependsOnCustom' },
{ option: { text: 'AB', value: 'AB', selected: false } } { option: { text: 'AB', value: 'AB', selected: false } }
) )
), )
resolveInitLock(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))
); );
}); });
}); });
}); });
});
});
}); });

View File

@ -1,5 +1,5 @@
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { QueryVariableModel, VariableHide } from '../types'; import { initialVariableModelState, QueryVariableModel } from '../types';
import { VariableAdapter, variableAdapters } from '../adapters'; import { VariableAdapter, variableAdapters } from '../adapters';
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { cleanVariables, variablesReducer, VariablesState } from './variablesReducer'; import { cleanVariables, variablesReducer, VariablesState } from './variablesReducer';
@ -29,43 +29,37 @@ describe('variablesReducer', () => {
it('then all variables except global variables should be removed', () => { it('then all variables except global variables should be removed', () => {
const initialState: VariablesState = { const initialState: VariablesState = {
'0': { '0': {
...initialVariableModelState,
id: '0', id: '0',
index: 0,
type: 'query', type: 'query',
name: 'Name-0', name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0', label: 'Label-0',
skipUrlSync: false,
global: false,
}, },
'1': { '1': {
...initialVariableModelState,
id: '1', id: '1',
index: 1,
type: 'query', type: 'query',
name: 'Name-1', name: 'Name-1',
hide: VariableHide.dontHide,
index: 1,
label: 'Label-1', label: 'Label-1',
skipUrlSync: false,
global: true, global: true,
}, },
'2': { '2': {
...initialVariableModelState,
id: '2', id: '2',
index: 2,
type: 'query', type: 'query',
name: 'Name-2', name: 'Name-2',
hide: VariableHide.dontHide,
index: 2,
label: 'Label-2', label: 'Label-2',
skipUrlSync: false,
global: false,
}, },
'3': { '3': {
...initialVariableModelState,
id: '3', id: '3',
index: 3,
type: 'query', type: 'query',
name: 'Name-3', name: 'Name-3',
hide: VariableHide.dontHide,
index: 3,
label: 'Label-3', label: 'Label-3',
skipUrlSync: false,
global: true, global: true,
}, },
}; };
@ -75,23 +69,21 @@ describe('variablesReducer', () => {
.whenActionIsDispatched(cleanVariables()) .whenActionIsDispatched(cleanVariables())
.thenStateShouldEqual({ .thenStateShouldEqual({
'1': { '1': {
...initialVariableModelState,
id: '1', id: '1',
index: 1,
type: 'query', type: 'query',
name: 'Name-1', name: 'Name-1',
hide: VariableHide.dontHide,
index: 1,
label: 'Label-1', label: 'Label-1',
skipUrlSync: false,
global: true, global: true,
}, },
'3': { '3': {
...initialVariableModelState,
id: '3', id: '3',
index: 3,
type: 'query', type: 'query',
name: 'Name-3', name: 'Name-3',
hide: VariableHide.dontHide,
index: 3,
label: 'Label-3', label: 'Label-3',
skipUrlSync: false,
global: true, global: true,
}, },
}); });
@ -102,14 +94,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => { it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = { const initialState: VariablesState = {
'0': { '0': {
...initialVariableModelState,
id: '0', id: '0',
index: 0,
type: 'query', type: 'query',
name: 'Name-0', name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0', label: 'Label-0',
skipUrlSync: false,
global: false,
}, },
}; };
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState); variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
@ -130,14 +120,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => { it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = { const initialState: VariablesState = {
'0': { '0': {
...initialVariableModelState,
id: '0', id: '0',
index: 0,
type: 'query', type: 'query',
name: 'Name-0', name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0', label: 'Label-0',
skipUrlSync: false,
global: false,
}, },
}; };
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState); variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
@ -154,14 +142,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => { it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = { const initialState: VariablesState = {
'0': { '0': {
...initialVariableModelState,
id: '0', id: '0',
index: 0,
type: 'query', type: 'query',
name: 'Name-0', name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0', label: 'Label-0',
skipUrlSync: false,
global: false,
}, },
}; };
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState); variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);

View File

@ -1,26 +1,27 @@
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { default as lodashDefaults } from 'lodash/defaults'; import { default as lodashDefaults } from 'lodash/defaults';
import { LoadingState } from '@grafana/data';
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { import {
addInitLock,
addVariable, addVariable,
changeVariableOrder, changeVariableOrder,
changeVariableProp, changeVariableProp,
duplicateVariable, duplicateVariable,
removeInitLock,
removeVariable, removeVariable,
resolveInitLock,
setCurrentVariableValue, setCurrentVariableValue,
sharedReducer, sharedReducer,
storeNewVariable, storeNewVariable,
variableStateCompleted,
variableStateFailed,
variableStateFetching,
variableStateNotStarted,
} from './sharedReducer'; } from './sharedReducer';
import { QueryVariableModel, VariableHide } from '../types'; import { QueryVariableModel, VariableHide } from '../types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID, toVariablePayload } from './types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID, toVariablePayload } from './types';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { createQueryVariableAdapter } from '../query/adapter'; import { createQueryVariableAdapter } from '../query/adapter';
import { initialQueryVariableModelState } from '../query/reducer'; import { initialQueryVariableModelState } from '../query/reducer';
import { Deferred } from '../../../core/utils/deferred';
import { getVariableState, getVariableTestContext } from './helpers'; import { getVariableState, getVariableTestContext } from './helpers';
import { initialVariablesState, VariablesState } from './variablesReducer'; import { initialVariablesState, VariablesState } from './variablesReducer';
import { changeVariableNameSucceeded } from '../editor/reducer'; import { changeVariableNameSucceeded } from '../editor/reducer';
@ -69,6 +70,8 @@ describe('sharedReducer', () => {
label: 'Label-0', label: 'Label-0',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'2': { '2': {
id: '2', id: '2',
@ -79,6 +82,8 @@ describe('sharedReducer', () => {
label: 'Label-2', label: 'Label-2',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
}); });
}); });
@ -101,6 +106,8 @@ describe('sharedReducer', () => {
label: 'Label-0', label: 'Label-0',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'2': { '2': {
id: '2', id: '2',
@ -111,6 +118,8 @@ describe('sharedReducer', () => {
label: 'Label-2', label: 'Label-2',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
}); });
}); });
@ -133,6 +142,8 @@ describe('sharedReducer', () => {
label: 'Label-0', label: 'Label-0',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'1': { '1': {
id: '1', id: '1',
@ -143,6 +154,8 @@ describe('sharedReducer', () => {
label: 'Label-1', label: 'Label-1',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'2': { '2': {
id: '2', id: '2',
@ -153,6 +166,8 @@ describe('sharedReducer', () => {
label: 'Label-2', label: 'Label-2',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'11': { '11': {
...initialQueryVariableModelState, ...initialQueryVariableModelState,
@ -182,6 +197,8 @@ describe('sharedReducer', () => {
label: 'Label-0', label: 'Label-0',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'1': { '1': {
id: '1', id: '1',
@ -192,6 +209,8 @@ describe('sharedReducer', () => {
label: 'Label-1', label: 'Label-1',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'2': { '2': {
id: '2', id: '2',
@ -202,6 +221,8 @@ describe('sharedReducer', () => {
label: 'Label-2', label: 'Label-2',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
}); });
}); });
@ -224,6 +245,8 @@ describe('sharedReducer', () => {
label: 'Label-0', label: 'Label-0',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'1': { '1': {
id: '1', id: '1',
@ -234,6 +257,8 @@ describe('sharedReducer', () => {
label: 'Label-1', label: 'Label-1',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
'2': { '2': {
id: '2', id: '2',
@ -244,6 +269,8 @@ describe('sharedReducer', () => {
label: 'Label-2', label: 'Label-2',
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
[NEW_VARIABLE_ID]: { [NEW_VARIABLE_ID]: {
id: NEW_VARIABLE_ID, id: NEW_VARIABLE_ID,
@ -254,6 +281,8 @@ describe('sharedReducer', () => {
label: `Label-${NEW_VARIABLE_ID}`, label: `Label-${NEW_VARIABLE_ID}`,
skipUrlSync: false, skipUrlSync: false,
global: false, global: false,
state: LoadingState.NotStarted,
error: null,
}, },
[11]: { [11]: {
...initialQueryVariableModelState, ...initialQueryVariableModelState,
@ -356,79 +385,87 @@ describe('sharedReducer', () => {
}); });
}); });
describe('when addInitLock is dispatched', () => { describe('when variableStateNotStarted is dispatched', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
const adapter = createQueryVariableAdapter(); const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, {}); const { initialState } = getVariableTestContext(adapter, {
state: LoadingState.Done,
error: 'Some error',
});
const payload = toVariablePayload({ id: '0', type: 'query' }); const payload = toVariablePayload({ id: '0', type: 'query' });
reducerTester<VariablesState>() reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState)) .givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(addInitLock(payload)) .whenActionIsDispatched(variableStateNotStarted(payload))
.thenStatePredicateShouldEqual(resultingState => {
// we need to remove initLock because instances will no be reference equal
const { initLock, ...resultingRest } = resultingState[0];
const expectedState = cloneDeep(initialState);
delete expectedState[0].initLock;
expect(resultingRest).toEqual(expectedState[0]);
// make sure that initLock is defined
expect(resultingState[0].initLock!).toBeDefined();
expect(resultingState[0].initLock!.promise).toBeDefined();
expect(resultingState[0].initLock!.resolve).toBeDefined();
expect(resultingState[0].initLock!.reject).toBeDefined();
return true;
});
});
});
describe('when resolveInitLock is dispatched', () => {
it('then state should be correct', () => {
const initLock = ({
resolve: jest.fn(),
reject: jest.fn(),
promise: jest.fn(),
} as unknown) as Deferred;
const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, { initLock });
const payload = toVariablePayload({ id: '0', type: 'query' });
reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(resolveInitLock(payload))
.thenStatePredicateShouldEqual(resultingState => {
// we need to remove initLock because instances will no be reference equal
const { initLock, ...resultingRest } = resultingState[0];
const expectedState = cloneDeep(initialState);
delete expectedState[0].initLock;
expect(resultingRest).toEqual(expectedState[0]);
// make sure that initLock is defined
expect(resultingState[0].initLock!).toBeDefined();
expect(resultingState[0].initLock!.promise).toBeDefined();
expect(resultingState[0].initLock!.resolve).toBeDefined();
expect(resultingState[0].initLock!.resolve).toHaveBeenCalledTimes(1);
expect(resultingState[0].initLock!.reject).toBeDefined();
return true;
});
});
});
describe('when removeInitLock is dispatched', () => {
it('then state should be correct', () => {
const initLock = ({
resolve: jest.fn(),
reject: jest.fn(),
promise: jest.fn(),
} as unknown) as Deferred;
const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, { initLock });
const payload = toVariablePayload({ id: '0', type: 'query' });
reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(removeInitLock(payload))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
'0': { '0': ({
...initialState[0], ...initialState[0],
initLock: null, state: LoadingState.NotStarted,
}, error: null,
} as unknown) as QueryVariableModel,
});
});
});
describe('when variableStateFetching is dispatched', () => {
it('then state should be correct', () => {
const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, {
state: LoadingState.Done,
error: 'Some error',
});
const payload = toVariablePayload({ id: '0', type: 'query' });
reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(variableStateFetching(payload))
.thenStateShouldEqual({
...initialState,
'0': ({
...initialState[0],
state: LoadingState.Loading,
error: null,
} as unknown) as QueryVariableModel,
});
});
});
describe('when variableStateCompleted is dispatched', () => {
it('then state should be correct', () => {
const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, {
state: LoadingState.Loading,
error: 'Some error',
});
const payload = toVariablePayload({ id: '0', type: 'query' });
reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(variableStateCompleted(payload))
.thenStateShouldEqual({
...initialState,
'0': ({
...initialState[0],
state: LoadingState.Done,
error: null,
} as unknown) as QueryVariableModel,
});
});
});
describe('when variableStateFailed is dispatched', () => {
it('then state should be correct', () => {
const adapter = createQueryVariableAdapter();
const { initialState } = getVariableTestContext(adapter, { state: LoadingState.Loading });
const payload = toVariablePayload({ id: '0', type: 'query' }, { error: 'Some error' });
reducerTester<VariablesState>()
.givenReducer(sharedReducer, cloneDeep(initialState))
.whenActionIsDispatched(variableStateFailed(payload))
.thenStateShouldEqual({
...initialState,
'0': ({
...initialState[0],
state: LoadingState.Error,
error: 'Some error',
} as unknown) as QueryVariableModel,
}); });
}); });
}); });

View File

@ -2,12 +2,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { default as lodashDefaults } from 'lodash/defaults'; import { default as lodashDefaults } from 'lodash/defaults';
import { VariableType } from '@grafana/data'; import { LoadingState, VariableType } from '@grafana/data';
import { VariableModel, VariableOption, VariableWithOptions } from '../types'; import { VariableModel, VariableOption, VariableWithOptions } from '../types';
import { AddVariable, getInstanceState, NEW_VARIABLE_ID, VariablePayload } from './types'; import { AddVariable, getInstanceState, NEW_VARIABLE_ID, VariablePayload } from './types';
import { variableAdapters } from '../adapters'; import { variableAdapters } from '../adapters';
import { changeVariableNameSucceeded } from '../editor/reducer'; import { changeVariableNameSucceeded } from '../editor/reducer';
import { Deferred } from '../../../core/utils/deferred';
import { initialVariablesState, VariablesState } from './variablesReducer'; import { initialVariablesState, VariablesState } from './variablesReducer';
import { isQuery } from '../guard'; import { isQuery } from '../guard';
@ -29,27 +28,33 @@ const sharedReducerSlice = createSlice({
state[id] = variable; state[id] = variable;
}, },
addInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => { variableStateNotStarted: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
const instanceState = getInstanceState(state, action.payload.id); const instanceState = getInstanceState(state, action.payload.id);
instanceState.initLock = new Deferred(); instanceState.state = LoadingState.NotStarted;
instanceState.error = null;
}, },
resolveInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => { variableStateFetching: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
const instanceState = getInstanceState(state, action.payload.id);
instanceState.state = LoadingState.Loading;
instanceState.error = null;
},
variableStateCompleted: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
const instanceState = getInstanceState(state, action.payload.id); const instanceState = getInstanceState(state, action.payload.id);
if (!instanceState) { if (!instanceState) {
// we might have cancelled a batch so then this state has been removed // we might have cancelled a batch so then this state has been removed
return; return;
} }
instanceState.initLock?.resolve(); instanceState.state = LoadingState.Done;
instanceState.error = null;
}, },
removeInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => { variableStateFailed: (state: VariablesState, action: PayloadAction<VariablePayload<{ error: any }>>) => {
const instanceState = getInstanceState(state, action.payload.id); const instanceState = getInstanceState(state, action.payload.id);
if (!instanceState) { if (!instanceState) {
// we might have cancelled a batch so then this state has been removed // we might have cancelled a batch so then this state has been removed
return; return;
} }
instanceState.initLock = null; instanceState.state = LoadingState.Error;
instanceState.error = action.payload.data.error;
}, },
removeVariable: (state: VariablesState, action: PayloadAction<VariablePayload<{ reIndex: boolean }>>) => { removeVariable: (state: VariablesState, action: PayloadAction<VariablePayload<{ reIndex: boolean }>>) => {
delete state[action.payload.id]; delete state[action.payload.id];
@ -173,7 +178,6 @@ const sharedReducerSlice = createSlice({
export const sharedReducer = sharedReducerSlice.reducer; export const sharedReducer = sharedReducerSlice.reducer;
export const { export const {
addInitLock,
removeVariable, removeVariable,
addVariable, addVariable,
changeVariableProp, changeVariableProp,
@ -182,8 +186,10 @@ export const {
duplicateVariable, duplicateVariable,
setCurrentVariableValue, setCurrentVariableValue,
changeVariableType, changeVariableType,
removeInitLock, variableStateNotStarted,
resolveInitLock, variableStateFetching,
variableStateCompleted,
variableStateFailed,
} = sharedReducerSlice.actions; } = sharedReducerSlice.actions;
const hasTags = (option: VariableOption): boolean => { const hasTags = (option: VariableOption): boolean => {

View File

@ -1,8 +1,8 @@
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { SystemVariable, VariableHide } from '../types'; import { LoadingState } from '@grafana/data';
import { initialVariableModelState, SystemVariable, VariableHide } from '../types';
import { VariableAdapter } from '../adapters'; import { VariableAdapter } from '../adapters';
import { NEW_VARIABLE_ID } from '../state/types';
import { Deferred } from '../../../core/utils/deferred';
import { VariablePickerProps } from '../pickers/types'; import { VariablePickerProps } from '../pickers/types';
import { VariableEditorProps } from '../editor/types'; import { VariableEditorProps } from '../editor/types';
@ -12,16 +12,12 @@ export const createSystemVariableAdapter = (): VariableAdapter<SystemVariable<an
description: '', description: '',
name: 'system', name: 'system',
initialState: { initialState: {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
type: 'system', type: 'system',
name: '',
label: (null as unknown) as string,
hide: VariableHide.hideVariable, hide: VariableHide.hideVariable,
skipUrlSync: true, skipUrlSync: true,
current: { value: { toString: () => '' } }, current: { value: { toString: () => '' } },
index: -1, state: LoadingState.Done,
initLock: (null as unknown) as Deferred,
}, },
reducer: (state: any, action: any) => state, reducer: (state: any, action: any) => state,
picker: (null as unknown) as ComponentType<VariablePickerProps>, picker: (null as unknown) as ComponentType<VariablePickerProps>,

View File

@ -1,11 +1,11 @@
import React, { ChangeEvent, FocusEvent, KeyboardEvent, PureComponent } from 'react'; import React, { ChangeEvent, FocusEvent, KeyboardEvent, PureComponent } from 'react';
import { TextBoxVariableModel } from '../types'; import { TextBoxVariableModel } from '../types';
import { toVariablePayload } from '../state/types'; import { toVariableIdentifier, toVariablePayload } from '../state/types';
import { dispatch } from '../../../store/store'; import { dispatch } from '../../../store/store';
import { variableAdapters } from '../adapters';
import { changeVariableProp } from '../state/sharedReducer'; import { changeVariableProp } from '../state/sharedReducer';
import { VariablePickerProps } from '../pickers/types'; import { VariablePickerProps } from '../pickers/types';
import { updateOptions } from '../state/actions';
export interface Props extends VariablePickerProps<TextBoxVariableModel> {} export interface Props extends VariablePickerProps<TextBoxVariableModel> {}
@ -18,13 +18,13 @@ export class TextBoxVariablePicker extends PureComponent<Props> {
onQueryBlur = (event: FocusEvent<HTMLInputElement>) => { onQueryBlur = (event: FocusEvent<HTMLInputElement>) => {
if (this.props.variable.current.value !== this.props.variable.query) { if (this.props.variable.current.value !== this.props.variable.query) {
variableAdapters.get(this.props.variable.type).updateOptions(this.props.variable); dispatch(updateOptions(toVariableIdentifier(this.props.variable)));
} }
}; };
onQueryKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { onQueryKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.keyCode === 13 && this.props.variable.current.value !== this.props.variable.query) { if (event.keyCode === 13 && this.props.variable.current.value !== this.props.variable.query) {
variableAdapters.get(this.props.variable.type).updateOptions(this.props.variable); dispatch(updateOptions(toVariableIdentifier(this.props.variable)));
} }
}; };

View File

@ -32,7 +32,7 @@ export const createTextBoxVariableAdapter = (): VariableAdapter<TextBoxVariableM
await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable))); await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable)));
}, },
getSaveModel: variable => { getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable); const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest; return rest;
}, },
getValueForUrl: variable => { getValueForUrl: variable => {

View File

@ -1,22 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TextBoxVariableModel, VariableHide, VariableOption } from '../types'; import { initialVariableModelState, TextBoxVariableModel, VariableOption } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types'; import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer'; import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialTextBoxVariableModelState: TextBoxVariableModel = { export const initialTextBoxVariableModelState: TextBoxVariableModel = {
id: NEW_VARIABLE_ID, ...initialVariableModelState,
global: false,
index: -1,
type: 'textbox', type: 'textbox',
name: '',
label: '',
hide: VariableHide.dontHide,
query: '', query: '',
current: {} as VariableOption, current: {} as VariableOption,
options: [], options: [],
skipUrlSync: false,
initLock: null,
}; };
export const textBoxVariableSlice = createSlice({ export const textBoxVariableSlice = createSlice({

View File

@ -1,5 +1,5 @@
import { Deferred } from '../../core/utils/deferred'; import { LoadingState, VariableModel as BaseVariableModel, VariableType } from '@grafana/data';
import { VariableModel as BaseVariableModel } from '@grafana/data'; import { NEW_VARIABLE_ID } from './state/types';
export enum VariableRefresh { export enum VariableRefresh {
never, never,
@ -125,5 +125,19 @@ export interface VariableModel extends BaseVariableModel {
hide: VariableHide; hide: VariableHide;
skipUrlSync: boolean; skipUrlSync: boolean;
index: number; index: number;
initLock?: Deferred | null; state: LoadingState;
error: any | null;
} }
export const initialVariableModelState: VariableModel = {
id: NEW_VARIABLE_ID,
name: '',
label: null,
type: ('' as unknown) as VariableType,
global: false,
index: -1,
hide: VariableHide.dontHide,
skipUrlSync: false,
state: LoadingState.NotStarted,
error: null,
};

View File

@ -6,7 +6,7 @@ import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor'; import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor';
import { CloudWatchDatasource } from '../datasource'; import { CloudWatchDatasource } from '../datasource';
import { CustomVariableModel, VariableHide } from '../../../../features/variables/types'; import { CustomVariableModel, initialVariableModelState } from '../../../../features/variables/types';
const setup = () => { const setup = () => {
const instanceSettings = { const instanceSettings = {
@ -15,6 +15,7 @@ const setup = () => {
const templateSrv = new TemplateSrv(); const templateSrv = new TemplateSrv();
const variable: CustomVariableModel = { const variable: CustomVariableModel = {
...initialVariableModelState,
id: 'var3', id: 'var3',
index: 0, index: 0,
name: 'var3', name: 'var3',
@ -27,11 +28,7 @@ const setup = () => {
multi: true, multi: true,
includeAll: false, includeAll: false,
query: '', query: '',
hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
templateSrv.init([variable]); templateSrv.init([variable]);

View File

@ -3,26 +3,26 @@ import { CloudWatchDatasource, MAX_ATTEMPTS } from '../datasource';
import * as redux from 'app/store/store'; import * as redux from 'app/store/store';
import { import {
DataFrame, DataFrame,
DataQueryErrorType,
DataQueryResponse, DataQueryResponse,
DataSourceInstanceSettings, DataSourceInstanceSettings,
dateMath, dateMath,
getFrameDisplayName, getFrameDisplayName,
DataQueryErrorType,
} from '@grafana/data'; } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { import {
CloudWatchLogsQuery,
CloudWatchLogsQueryStatus, CloudWatchLogsQueryStatus,
CloudWatchMetricsQuery, CloudWatchMetricsQuery,
CloudWatchQuery, CloudWatchQuery,
LogAction, LogAction,
CloudWatchLogsQuery,
} from '../types'; } from '../types';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { convertToStoreState } from '../../../../../test/helpers/convertToStoreState'; import { convertToStoreState } from '../../../../../test/helpers/convertToStoreState';
import { getTemplateSrvDependencies } from 'test/helpers/getTemplateSrvDependencies'; import { getTemplateSrvDependencies } from 'test/helpers/getTemplateSrvDependencies';
import { of, interval } from 'rxjs'; import { interval, of } from 'rxjs';
import { CustomVariableModel, VariableHide } from '../../../../features/variables/types'; import { CustomVariableModel, initialVariableModelState, VariableHide } from '../../../../features/variables/types';
import { TimeSrvStub } from '../../../../../test/specs/helpers'; import { TimeSrvStub } from '../../../../../test/specs/helpers';
import * as rxjsUtils from '../utils/rxjs/increasingInterval'; import * as rxjsUtils from '../utils/rxjs/increasingInterval';
@ -376,6 +376,7 @@ describe('CloudWatchDatasource', () => {
it('should generate the correct query with interval variable', async () => { it('should generate the correct query with interval variable', async () => {
const period: CustomVariableModel = { const period: CustomVariableModel = {
...initialVariableModelState,
id: 'period', id: 'period',
name: 'period', name: 'period',
index: 0, index: 0,
@ -386,9 +387,6 @@ describe('CloudWatchDatasource', () => {
query: '', query: '',
hide: VariableHide.dontHide, hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
templateSrv.init([period]); templateSrv.init([period]);
@ -821,6 +819,7 @@ describe('CloudWatchDatasource', () => {
let requestParams: { queries: CloudWatchMetricsQuery[] }; let requestParams: { queries: CloudWatchMetricsQuery[] };
beforeEach(() => { beforeEach(() => {
const var1: CustomVariableModel = { const var1: CustomVariableModel = {
...initialVariableModelState,
id: 'var1', id: 'var1',
name: 'var1', name: 'var1',
index: 0, index: 0,
@ -831,11 +830,9 @@ describe('CloudWatchDatasource', () => {
query: '', query: '',
hide: VariableHide.dontHide, hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
const var2: CustomVariableModel = { const var2: CustomVariableModel = {
...initialVariableModelState,
id: 'var2', id: 'var2',
name: 'var2', name: 'var2',
index: 1, index: 1,
@ -846,11 +843,9 @@ describe('CloudWatchDatasource', () => {
query: '', query: '',
hide: VariableHide.dontHide, hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
const var3: CustomVariableModel = { const var3: CustomVariableModel = {
...initialVariableModelState,
id: 'var3', id: 'var3',
name: 'var3', name: 'var3',
index: 2, index: 2,
@ -865,11 +860,9 @@ describe('CloudWatchDatasource', () => {
query: '', query: '',
hide: VariableHide.dontHide, hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
const var4: CustomVariableModel = { const var4: CustomVariableModel = {
...initialVariableModelState,
id: 'var4', id: 'var4',
name: 'var4', name: 'var4',
index: 3, index: 3,
@ -884,9 +877,6 @@ describe('CloudWatchDatasource', () => {
query: '', query: '',
hide: VariableHide.dontHide, hide: VariableHide.dontHide,
type: 'custom', type: 'custom',
label: null,
skipUrlSync: false,
global: false,
}; };
const variables = [var1, var2, var3, var4]; const variables = [var1, var2, var3, var4];
const state = convertToStoreState(variables); const state = convertToStoreState(variables);

View File

@ -0,0 +1,15 @@
export const silenceConsoleOutput = () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(jest.fn());
jest.spyOn(console, 'error').mockImplementation(jest.fn());
jest.spyOn(console, 'debug').mockImplementation(jest.fn());
jest.spyOn(console, 'info').mockImplementation(jest.fn());
});
afterEach(() => {
jest.spyOn(console, 'log').mockRestore();
jest.spyOn(console, 'error').mockRestore();
jest.spyOn(console, 'debug').mockRestore();
jest.spyOn(console, 'info').mockRestore();
});
};