Commit 845bc7c4 by Hugo Häggmark Committed by GitHub

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
parent add777ad
......@@ -30,15 +30,11 @@ describe.skip('Variables', () => {
if (!lastUid || !lastData) {
e2e.flows.addDataSource();
e2e.flows.addDashboard();
lastUid = 'test';
lastData = 'test';
} 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`, () => {
......@@ -254,7 +250,7 @@ const createQueryVariable = ({ name, label, dataSourceName, query }: CreateQuery
expect(input.attr('placeholder')).equals('blank = auto');
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) => {
......
......@@ -85,8 +85,7 @@ export const Pages = {
selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch',
selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field',
previewOfValuesOption: 'Variable editor Preview of Values option',
addButton: 'Variable editor Add button',
updateButton: 'Variable editor Update button',
submitButton: 'Variable editor Submit button',
},
QueryVariable: {
queryOptionsDataSourceSelect: 'Variable editor Form Query DataSource select',
......
......@@ -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;
};
......
......@@ -28,7 +28,7 @@ export const createAdHocVariableAdapter = (): VariableAdapter<AdHocVariableModel
},
updateOptions: noop,
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest;
},
getValueForUrl: variable => {
......
import { AdHocVariableFilter, AdHocVariableModel, VariableHide } from 'app/features/variables/types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types';
import { AdHocVariableFilter, AdHocVariableModel, initialVariableModelState } from 'app/features/variables/types';
import { getInstanceState, VariablePayload } from '../state/types';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
......@@ -13,15 +13,8 @@ export interface AdHocVariableEditorState {
}
export const initialAdHocVariableModelState: AdHocVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'adhoc',
name: '',
hide: VariableHide.dontHide,
label: '',
skipUrlSync: false,
index: -1,
initLock: null,
datasource: null,
filters: [],
};
......
......@@ -4,7 +4,7 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from 'app/features/variables/state/reducers';
import { updateConstantVariableOptions } from './actions';
import { getRootReducer } from '../state/helpers';
import { ConstantVariableModel, VariableHide, VariableOption } from '../types';
import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types';
import { toVariablePayload } from '../state/types';
import { createConstantOptionsFromQuery } from './reducer';
import { addVariable, setCurrentVariableValue } from '../state/sharedReducer';
......@@ -21,9 +21,11 @@ describe('constant actions', () => {
};
const variable: ConstantVariableModel = {
type: 'constant',
...initialVariableModelState,
id: '0',
global: false,
index: 0,
type: 'constant',
name: 'Constant',
current: {
value: '',
text: '',
......@@ -31,11 +33,6 @@ describe('constant actions', () => {
},
options: [],
query: 'A',
name: 'Constant',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
};
const tester = await reduxTester<{ templating: TemplatingState }>()
......
......@@ -31,7 +31,7 @@ export const createConstantVariableAdapter = (): VariableAdapter<ConstantVariabl
await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable)));
},
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest;
},
getValueForUrl: variable => {
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ConstantVariableModel, VariableHide, VariableOption } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types';
import { ConstantVariableModel, initialVariableModelState, VariableHide, VariableOption } from '../types';
import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialConstantVariableModelState: ConstantVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'constant',
name: '',
hide: VariableHide.hideVariable,
label: '',
query: '',
current: {} as VariableOption,
options: [],
skipUrlSync: false,
index: -1,
initLock: null,
};
export const constantVariableSlice = createSlice({
......
......@@ -3,7 +3,7 @@ import { updateCustomVariableOptions } from './actions';
import { createCustomVariableAdapter } from './adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../state/helpers';
import { CustomVariableModel, VariableHide, VariableOption } from '../types';
import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types';
import { toVariablePayload } from '../state/types';
import { addVariable, setCurrentVariableValue } from '../state/sharedReducer';
import { TemplatingState } from '../state/reducers';
......@@ -21,9 +21,11 @@ describe('custom actions', () => {
};
const variable: CustomVariableModel = {
type: 'custom',
...initialVariableModelState,
id: '0',
global: false,
index: 0,
type: 'custom',
name: 'Custom',
current: {
value: '',
text: '',
......@@ -42,11 +44,6 @@ describe('custom actions', () => {
},
],
query: 'A,B',
name: 'Custom',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
multi: true,
includeAll: false,
};
......
......@@ -32,7 +32,7 @@ export const createCustomVariableAdapter = (): VariableAdapter<CustomVariableMod
await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable)));
},
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest;
},
getValueForUrl: variable => {
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { CustomVariableModel, VariableHide, VariableOption } from '../types';
import {
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
NEW_VARIABLE_ID,
VariablePayload,
} from '../state/types';
import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialCustomVariableModelState: CustomVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'custom',
multi: false,
includeAll: false,
allValue: null,
query: '',
options: [],
current: {} as VariableOption,
name: '',
type: 'custom',
label: null,
hide: VariableHide.dontHide,
skipUrlSync: false,
index: -1,
initLock: null,
};
export const customVariableSlice = createSlice({
......
......@@ -35,7 +35,7 @@ export const createDataSourceVariableAdapter = (): VariableAdapter<DataSourceVar
await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable)));
},
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return { ...rest, options: [] };
},
getValueForUrl: variable => {
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { DataSourceVariableModel, VariableHide, VariableOption, VariableRefresh } from '../types';
import {
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
NEW_VARIABLE_ID,
VariablePayload,
} from '../state/types';
import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
import { DataSourceSelectItem } from '@grafana/data';
......@@ -15,12 +9,8 @@ export interface DataSourceVariableEditorState {
}
export const initialDataSourceVariableModelState: DataSourceVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'datasource',
name: '',
hide: VariableHide.dontHide,
label: '',
current: {} as VariableOption,
regex: '',
options: [],
......@@ -28,9 +18,6 @@ export const initialDataSourceVariableModelState: DataSourceVariableModel = {
multi: false,
includeAll: false,
refresh: VariableRefresh.onDashboardLoad,
skipUrlSync: false,
index: -1,
initLock: null,
};
export const dataSourceVariableSlice = createSlice({
......
import React, { ChangeEvent, FormEvent, PureComponent } from 'react';
import isEqual from 'lodash/isEqual';
import { AppEvents, VariableType } from '@grafana/data';
import { InlineFormLabel } from '@grafana/ui';
import { AppEvents, LoadingState, VariableType } from '@grafana/data';
import { Icon, InlineFormLabel } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
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 { appEvents } from '../../../core/core';
import { VariableValuesPreview } from './VariableValuesPreview';
......@@ -17,6 +17,7 @@ import { getVariable } from '../state/selectors';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
import { OnPropChangeArguments } from './types';
import { changeVariableProp, changeVariableType } from '../state/sharedReducer';
import { updateOptions } from '../state/actions';
export interface OwnProps {
identifier: VariableIdentifier;
......@@ -35,6 +36,7 @@ interface DispatchProps {
onEditorUpdate: typeof onEditorUpdate;
onEditorAdd: typeof onEditorAdd;
changeVariableType: typeof changeVariableType;
updateOptions: typeof updateOptions;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
......@@ -88,7 +90,7 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => {
this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue }));
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() {
const { variable } = this.props;
const EditorToRender = variableAdapters.get(this.props.variable.type).editor;
if (!EditorToRender) {
return null;
}
const newVariable = this.props.variable.id && this.props.variable.id === NEW_VARIABLE_ID;
const loading = variable.state === LoadingState.Loading;
return (
<div>
......@@ -201,24 +205,15 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
<VariableValuesPreview variable={this.props.variable} />
<div className="gf-form-button-row p-y-0">
{!newVariable && (
<button
type="submit"
className="btn btn-primary"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.updateButton}
>
Update
</button>
)}
{newVariable && (
<button
type="submit"
className="btn btn-primary"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.addButton}
>
Add
</button>
)}
<button
type="submit"
className="btn btn-primary"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton}
disabled={loading}
>
{newVariable ? 'Add' : 'Update'}
{loading ? <Icon className="spin-clockwise" name="sync" size="sm" style={{ marginLeft: '2px' }} /> : null}
</button>
</div>
</form>
</div>
......@@ -239,6 +234,7 @@ const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
onEditorUpdate,
onEditorAdd,
changeVariableType,
updateOptions,
};
export const VariableEditorEditor = connectWithStore(
......
......@@ -19,6 +19,7 @@ import {
import cloneDeep from 'lodash/cloneDeep';
import { VariableType } from '@grafana/data';
import { addVariable, removeVariable, storeNewVariable } from '../state/sharedReducer';
import { updateOptions } from '../state/actions';
export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult<void> => {
return async dispatch => {
......@@ -36,9 +37,8 @@ export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResu
};
export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult<void> => {
return async (dispatch, getState) => {
const variableInState = getVariable(identifier.id, getState());
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
return async dispatch => {
await dispatch(updateOptions(identifier));
dispatch(switchToListMode());
};
};
......@@ -48,8 +48,7 @@ export const onEditorAdd = (identifier: VariableIdentifier): ThunkResult<void> =
const newVariableInState = getVariable(NEW_VARIABLE_ID, getState());
const id = newVariableInState.name;
dispatch(storeNewVariable(toVariablePayload({ type: identifier.type, id })));
const variableInState = getVariable(id, getState());
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
await dispatch(updateOptions(identifier));
dispatch(switchToListMode());
dispatch(removeVariable(toVariablePayload({ type: identifier.type, id: NEW_VARIABLE_ID }, { reIndex: false })));
};
......
......@@ -2,22 +2,23 @@ import { getRootReducer } from '../state/helpers';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from '../state/reducers';
import { toVariableIdentifier, toVariablePayload } from '../state/types';
import {
updateAutoValue,
UpdateAutoValueDependencies,
updateIntervalVariableOptions,
UpdateIntervalVariableOptionsDependencies,
} from './actions';
import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions';
import { createIntervalOptions } from './reducer';
import { setCurrentVariableValue, addVariable } from '../state/sharedReducer';
import {
addVariable,
setCurrentVariableValue,
variableStateFailed,
variableStateFetching,
} from '../state/sharedReducer';
import { variableAdapters } from '../adapters';
import { createIntervalVariableAdapter } from './adapter';
import { Emitter } from 'app/core/core';
import { AppEvents, dateTime } from '@grafana/data';
import { dateTime } from '@grafana/data';
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { TemplateSrv } from '../../templating/template_srv';
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', () => {
variableAdapters.setInit(() => [createIntervalVariableAdapter()]);
......@@ -45,8 +46,9 @@ describe('interval actions', () => {
});
});
describe('when updateIntervalVariableOptions is dispatched but something throws', () => {
it('then an app event should be emitted', async () => {
describe('when updateOptions is dispatched but something throws', () => {
silenceConsoleOutput();
it('then an notifyApp action should be dispatched', async () => {
const timeSrvMock = ({
timeRange: jest.fn().mockReturnValue({
from: dateTime(new Date())
......@@ -67,23 +69,36 @@ describe('interval actions', () => {
.withAuto(true)
.withAutoMin('1xyz') // illegal interval string
.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())
.whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })))
.whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval), dependencies), true);
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(', ')}"`,
]);
.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;
});
setTimeSrv(originalTimeSrv);
});
});
......
import { AppEvents, rangeUtil } from '@grafana/data';
import { rangeUtil } from '@grafana/data';
import { toVariablePayload, VariableIdentifier } from '../state/types';
import { ThunkResult } from '../../../types';
......@@ -8,23 +8,11 @@ import { getVariable } from '../state/selectors';
import { IntervalVariableModel } from '../types';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv';
import appEvents from '../../../core/app_events';
export interface UpdateIntervalVariableOptionsDependencies {
appEvents: typeof appEvents;
}
export const updateIntervalVariableOptions = (
identifier: VariableIdentifier,
dependencies: UpdateIntervalVariableOptionsDependencies = { appEvents: appEvents }
): ThunkResult<void> => async dispatch => {
try {
await dispatch(createIntervalOptions(toVariablePayload(identifier)));
await dispatch(updateAutoValue(identifier));
await dispatch(validateVariableSelectionState(identifier));
} catch (error) {
dependencies.appEvents.emit(AppEvents.alertError, ['Templating', error.message]);
}
export const updateIntervalVariableOptions = (identifier: VariableIdentifier): ThunkResult<void> => async dispatch => {
await dispatch(createIntervalOptions(toVariablePayload(identifier)));
await dispatch(updateAutoValue(identifier));
await dispatch(validateVariableSelectionState(identifier));
};
export interface UpdateAutoValueDependencies {
......
......@@ -33,7 +33,7 @@ export const createIntervalVariableAdapter = (): VariableAdapter<IntervalVariabl
await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable)));
},
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest;
},
getValueForUrl: variable => {
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IntervalVariableModel, VariableHide, VariableOption, VariableRefresh } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types';
import { initialVariableModelState, IntervalVariableModel, VariableOption, VariableRefresh } from '../types';
import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
import _ from 'lodash';
export const initialIntervalVariableModelState: IntervalVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'interval',
name: '',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
auto_count: 30,
auto_min: '10s',
options: [],
......@@ -19,8 +14,6 @@ export const initialIntervalVariableModelState: IntervalVariableModel = {
query: '1m,10m,30m,1h,6h,12h,1d,7d,14d,30d',
refresh: VariableRefresh.onTimeRangeChanged,
current: {} as VariableOption,
index: -1,
initLock: null,
};
export const intervalVariableSlice = createSlice({
......
......@@ -11,6 +11,7 @@ import { VariableOptions } from '../shared/VariableOptions';
import { isQuery } from '../../guard';
import { VariablePickerProps } from '../types';
import { formatVariableLabel } from '../../shared/formatVariable';
import { LoadingState } from '@grafana/data';
interface OwnProps extends VariablePickerProps<VariableWithMultiSupport> {}
......@@ -67,8 +68,9 @@ export class OptionsPickerUnconnected extends PureComponent<Props> {
const linkText = formatVariableLabel(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) {
......
import { reduxTester } from '../../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../../state/helpers';
import { TemplatingState } from '../../state/reducers';
import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../../types';
import { initialVariableModelState, QueryVariableModel, VariableRefresh, VariableSort } from '../../types';
import {
hideOptions,
moveOptionsHighlight,
......@@ -404,17 +404,14 @@ describe('options picker actions', () => {
function createMultiVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel {
return {
...initialVariableModelState,
type: 'query',
id: '0',
global: false,
index: 0,
current: createOption([]),
options: [],
query: 'options-query',
name: 'Constant',
label: '',
hide: VariableHide.dontHide,
skipUrlSync: false,
index: 0,
datasource: 'datasource',
definition: '',
sort: VariableSort.alphabeticalAsc,
......
import React, { PureComponent } from 'react';
import { getTagColorsFromName, Icon } from '@grafana/ui';
import React, { FC, MouseEvent, useCallback } from 'react';
import { css } from 'emotion';
import { getTagColorsFromName, Icon, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { GrafanaTheme } from '@grafana/data';
import { VariableTag } from '../../types';
import { css } from 'emotion';
interface Props {
onClick: () => void;
text: string;
tags: VariableTag[];
loading: boolean;
}
export class VariableLink extends PureComponent<Props> {
onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
event.preventDefault();
this.props.onClick();
};
render() {
const { tags = [], text } = this.props;
export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, tags, text }) => {
const styles = useStyles(getStyles);
const onClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
event.preventDefault();
propsOnClick();
},
[propsOnClick]
);
if (loading) {
return (
<a
onClick={this.onClick}
className="variable-value-link"
<div
className={styles.container}
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
title={text}
>
<span
className={css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{text}
{tags.map(tag => {
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
return (
<span key={`${tag.text}`}>
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
&nbsp;&nbsp;
<Icon name="tag-alt" />
&nbsp; {tag.text}
</span>
</span>
);
})}
</span>
<Icon name="angle-down" size="sm" />
</a>
<VariableLinkText tags={tags} text={text} />
<Icon className="spin-clockwise" name="sync" size="xs" />
</div>
);
}
}
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}
{tags.map(tag => {
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
return (
<span key={`${tag.text}`}>
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
&nbsp;&nbsp;
<Icon name="tag-alt" />
&nbsp; {tag.text}
</span>
</span>
);
})}
</span>
);
};
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;
`,
});
import { LoadingState } from '@grafana/data';
import { variableAdapters } from '../adapters';
import { createQueryVariableAdapter } from './adapter';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { getRootReducer } from '../state/helpers';
import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../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 {
changeQueryVariableDataSource,
......@@ -21,6 +30,9 @@ import {
} from '../editor/reducer';
import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor';
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> = {
datasource: {
......@@ -215,6 +227,7 @@ describe('query actions', () => {
});
describe('when updateQueryVariableOptions is dispatched and fails for variable open in editor', () => {
silenceConsoleOutput();
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const error = { message: 'failed to fetch metrics' };
......@@ -225,15 +238,23 @@ describe('query actions', () => {
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(setIdInEditor({ id: variable.id }))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
.whenAsyncActionIsDispatched(updateOptions(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [clearErrors, errorOccurred] = actions;
const expectedNumberOfActions = 2;
tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
const expectedNumberOfActions = 5;
expect(errorOccurred).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message }));
expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' }));
return actions.length === expectedNumberOfActions;
expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(variable)));
expect(dispatchedActions[1]).toEqual(removeVariableEditorError({ errorProp: 'update' }));
expect(dispatchedActions[2]).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message }));
expect(dispatchedActions[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 update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [clearError, changeQuery, changeDefinition, updateOptions, updateTags, setOption] = actions;
const expectedNumberOfActions = 6;
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' }));
expect(changeQuery).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))
);
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;
});
tester.thenDispatchedActionsShouldEqual(
removeVariableEditorError({ errorProp: 'query' }),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
variableStateFetching(toVariablePayload(variable)),
updateVariableOptions(toVariablePayload(variable, update)),
updateVariableTags(toVariablePayload(variable, tagsMetrics)),
setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
);
});
});
......@@ -473,22 +487,15 @@ describe('query actions', () => {
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [clearError, changeQuery, changeDefinition, updateOptions, setOption] = actions;
const expectedNumberOfActions = 5;
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' }));
expect(changeQuery).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))
);
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;
});
tester.thenDispatchedActionsShouldEqual(
removeVariableEditorError({ errorProp: 'query' }),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
variableStateFetching(toVariablePayload(variable)),
updateVariableOptions(toVariablePayload(variable, update)),
setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
);
});
});
......@@ -509,22 +516,15 @@ describe('query actions', () => {
const option = createOption('A');
const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [clearError, changeQuery, changeDefinition, updateOptions, setOption] = actions;
const expectedNumberOfActions = 5;
expect(clearError).toEqual(removeVariableEditorError({ errorProp: 'query' }));
expect(changeQuery).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))
);
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;
});
tester.thenDispatchedActionsShouldEqual(
removeVariableEditorError({ errorProp: 'query' }),
changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })),
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
variableStateFetching(toVariablePayload(variable)),
updateVariableOptions(toVariablePayload(variable, update)),
setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
);
});
});
......@@ -588,6 +588,8 @@ function createVariable(extend?: Partial<QueryVariableModel>): QueryVariableMode
regex: '',
multi: true,
includeAll: true,
state: LoadingState.NotStarted,
error: null,
...(extend ?? {}),
};
}
......
import { AppEvents, DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { validateVariableSelectionState } from '../state/actions';
import { DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data';
import { toDataQueryError, getTemplateSrv } from '@grafana/runtime';
import { updateOptions, validateVariableSelectionState } from '../state/actions';
import { QueryVariableModel, VariableRefresh } from '../types';
import { ThunkResult } from '../../../types';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import appEvents from '../../../core/app_events';
import { importDataSourcePlugin } from '../../plugins/plugin_loader';
import DefaultVariableQueryEditor from '../editor/DefaultVariableQueryEditor';
import { getVariable } from '../state/selectors';
import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer';
import { variableAdapters } from '../adapters';
import { changeVariableProp } from '../state/sharedReducer';
import { updateVariableOptions, updateVariableTags } from './reducer';
import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types';
......@@ -60,17 +59,12 @@ export const updateQueryVariableOptions = (
await dispatch(validateVariableSelectionState(toVariableIdentifier(variableInState)));
}
} catch (err) {
console.error(err);
if (err.data && err.data.message) {
err.message = err.data.message;
}
const error = toDataQueryError(err);
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',
'Template variables could not be initialized: ' + err.message,
]);
throw error;
}
};
};
......@@ -126,7 +120,7 @@ export const changeQueryVariableQuery = (
dispatch(removeVariableEditorError({ errorProp: 'query' }));
dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })));
dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition })));
await variableAdapters.get(identifier.type).updateOptions(variableInState);
await dispatch(updateOptions(identifier));
};
const getTemplatedRegex = (variable: QueryVariableModel): string => {
......
......@@ -33,7 +33,7 @@ export const createQueryVariableAdapter = (): VariableAdapter<QueryVariableModel
await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter));
},
getSaveModel: variable => {
const { index, id, initLock, global, queryValue, ...rest } = cloneDeep(variable);
const { index, id, state, global, queryValue, ...rest } = cloneDeep(variable);
// remove options
if (variable.refresh !== VariableRefresh.never) {
return { ...rest, options: [] };
......
......@@ -2,13 +2,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import _ from 'lodash';
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 {
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
NEW_VARIABLE_ID,
NONE_VARIABLE_TEXT,
NONE_VARIABLE_VALUE,
VariablePayload,
......@@ -29,14 +35,8 @@ export interface QueryVariableEditorState {
}
export const initialQueryVariableModelState: QueryVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
index: -1,
...initialVariableModelState,
type: 'query',
name: '',
label: null,
hide: VariableHide.dontHide,
skipUrlSync: false,
datasource: null,
query: '',
regex: '',
......@@ -52,7 +52,6 @@ export const initialQueryVariableModelState: QueryVariableModel = {
tagsQuery: '',
tagValuesQuery: '',
definition: '',
initLock: null,
};
const sortVariableValues = (options: any[], sortOrder: VariableSort) => {
......
......@@ -20,13 +20,12 @@ import {
validateVariableSelectionState,
} from './actions';
import {
addInitLock,
addVariable,
changeVariableProp,
removeInitLock,
removeVariable,
resolveInitLock,
setCurrentVariableValue,
variableStateCompleted,
variableStateNotStarted,
} from './sharedReducer';
import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload } from './types';
import {
......@@ -98,16 +97,16 @@ describe('shared actions', () => {
// because uuid are dynamic we need to get the uuid from the resulting state
// an alternative would be to add our own uuids in the model above instead
expect(dispatchedActions[4]).toEqual(
addInitLock(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id }))
variableStateNotStarted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id }))
);
expect(dispatchedActions[5]).toEqual(
addInitLock(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id }))
variableStateNotStarted(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id }))
);
expect(dispatchedActions[6]).toEqual(
addInitLock(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id }))
variableStateNotStarted(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id }))
);
expect(dispatchedActions[7]).toEqual(
addInitLock(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id }))
variableStateNotStarted(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id }))
);
return true;
......@@ -128,36 +127,27 @@ describe('shared actions', () => {
preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query: {} } },
})
.givenRootReducer(getTemplatingAndLocationRootReducer())
.whenActionIsDispatched(variablesInitTransaction({ uid: '' }))
.whenActionIsDispatched(initDashboardTemplating(list))
.whenAsyncActionIsDispatched(processVariables(), true);
await tester.thenDispatchedActionsPredicateShouldEqual(dispatchedActions => {
expect(dispatchedActions.length).toEqual(8);
expect(dispatchedActions.length).toEqual(4);
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 }))
variableStateCompleted(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 }))
variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id }))
);
expect(dispatchedActions[4]).toEqual(
removeInitLock(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id }))
);
expect(dispatchedActions[5]).toEqual(
removeInitLock(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id }))
);
expect(dispatchedActions[6]).toEqual(
removeInitLock(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id }))
);
expect(dispatchedActions[7]).toEqual(
removeInitLock(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id }))
expect(dispatchedActions[3]).toEqual(
variableStateCompleted(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id }))
);
return true;
......@@ -578,12 +568,11 @@ describe('shared actions', () => {
expect(dispatchedActions[4]).toEqual(
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
);
expect(dispatchedActions[5]).toEqual(addInitLock(toVariablePayload(constant)));
expect(dispatchedActions[6]).toEqual(resolveInitLock(toVariablePayload(constant)));
expect(dispatchedActions[7]).toEqual(removeInitLock(toVariablePayload(constant)));
expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant)));
expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant)));
expect(dispatchedActions[8]).toEqual(variablesCompleteTransaction({ uid }));
return dispatchedActions.length === 9;
expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid }));
return dispatchedActions.length === 8;
});
});
});
......@@ -618,11 +607,10 @@ describe('shared actions', () => {
expect(dispatchedActions[6]).toEqual(
addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))
);
expect(dispatchedActions[7]).toEqual(addInitLock(toVariablePayload(constant)));
expect(dispatchedActions[8]).toEqual(resolveInitLock(toVariablePayload(constant)));
expect(dispatchedActions[9]).toEqual(removeInitLock(toVariablePayload(constant)));
expect(dispatchedActions[10]).toEqual(variablesCompleteTransaction({ uid }));
return dispatchedActions.length === 11;
expect(dispatchedActions[7]).toEqual(variableStateNotStarted(toVariablePayload(constant)));
expect(dispatchedActions[8]).toEqual(variableStateCompleted(toVariablePayload(constant)));
expect(dispatchedActions[9]).toEqual(variablesCompleteTransaction({ uid }));
return dispatchedActions.length === 10;
});
});
});
......
import { combineReducers } from '@reduxjs/toolkit';
import { LoadingState } from '@grafana/data';
import { NEW_VARIABLE_ID } from './types';
import { VariableHide, VariableModel } from '../types';
......@@ -25,6 +26,8 @@ export const getVariableState = (
label: `Label-${index}`,
skipUrlSync: false,
global: false,
state: LoadingState.NotStarted,
error: null,
};
}
......@@ -38,6 +41,8 @@ export const getVariableState = (
label: `Label-${NEW_VARIABLE_ID}`,
skipUrlSync: false,
global: false,
state: LoadingState.NotStarted,
error: null,
};
}
......
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { QueryVariableModel, VariableHide } from '../types';
import { initialVariableModelState, QueryVariableModel } from '../types';
import { VariableAdapter, variableAdapters } from '../adapters';
import { createAction } from '@reduxjs/toolkit';
import { cleanVariables, variablesReducer, VariablesState } from './variablesReducer';
......@@ -29,43 +29,37 @@ describe('variablesReducer', () => {
it('then all variables except global variables should be removed', () => {
const initialState: VariablesState = {
'0': {
...initialVariableModelState,
id: '0',
index: 0,
type: 'query',
name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0',
skipUrlSync: false,
global: false,
},
'1': {
...initialVariableModelState,
id: '1',
index: 1,
type: 'query',
name: 'Name-1',
hide: VariableHide.dontHide,
index: 1,
label: 'Label-1',
skipUrlSync: false,
global: true,
},
'2': {
...initialVariableModelState,
id: '2',
index: 2,
type: 'query',
name: 'Name-2',
hide: VariableHide.dontHide,
index: 2,
label: 'Label-2',
skipUrlSync: false,
global: false,
},
'3': {
...initialVariableModelState,
id: '3',
index: 3,
type: 'query',
name: 'Name-3',
hide: VariableHide.dontHide,
index: 3,
label: 'Label-3',
skipUrlSync: false,
global: true,
},
};
......@@ -75,23 +69,21 @@ describe('variablesReducer', () => {
.whenActionIsDispatched(cleanVariables())
.thenStateShouldEqual({
'1': {
...initialVariableModelState,
id: '1',
index: 1,
type: 'query',
name: 'Name-1',
hide: VariableHide.dontHide,
index: 1,
label: 'Label-1',
skipUrlSync: false,
global: true,
},
'3': {
...initialVariableModelState,
id: '3',
index: 3,
type: 'query',
name: 'Name-3',
hide: VariableHide.dontHide,
index: 3,
label: 'Label-3',
skipUrlSync: false,
global: true,
},
});
......@@ -102,14 +94,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = {
'0': {
...initialVariableModelState,
id: '0',
index: 0,
type: 'query',
name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0',
skipUrlSync: false,
global: false,
},
};
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
......@@ -130,14 +120,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = {
'0': {
...initialVariableModelState,
id: '0',
index: 0,
type: 'query',
name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0',
skipUrlSync: false,
global: false,
},
};
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
......@@ -154,14 +142,12 @@ describe('variablesReducer', () => {
it('then the reducer for that variableAdapter should be invoked', () => {
const initialState: VariablesState = {
'0': {
...initialVariableModelState,
id: '0',
index: 0,
type: 'query',
name: 'Name-0',
hide: VariableHide.dontHide,
index: 0,
label: 'Label-0',
skipUrlSync: false,
global: false,
},
};
variableAdapters.get('mock').reducer = jest.fn().mockReturnValue(initialState);
......
......@@ -2,12 +2,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
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 { AddVariable, getInstanceState, NEW_VARIABLE_ID, VariablePayload } from './types';
import { variableAdapters } from '../adapters';
import { changeVariableNameSucceeded } from '../editor/reducer';
import { Deferred } from '../../../core/utils/deferred';
import { initialVariablesState, VariablesState } from './variablesReducer';
import { isQuery } from '../guard';
......@@ -29,27 +28,33 @@ const sharedReducerSlice = createSlice({
state[id] = variable;
},
addInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
variableStateNotStarted: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
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);
if (!instanceState) {
// we might have cancelled a batch so then this state has been removed
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);
if (!instanceState) {
// we might have cancelled a batch so then this state has been removed
return;
}
instanceState.initLock = null;
instanceState.state = LoadingState.Error;
instanceState.error = action.payload.data.error;
},
removeVariable: (state: VariablesState, action: PayloadAction<VariablePayload<{ reIndex: boolean }>>) => {
delete state[action.payload.id];
......@@ -173,7 +178,6 @@ const sharedReducerSlice = createSlice({
export const sharedReducer = sharedReducerSlice.reducer;
export const {
addInitLock,
removeVariable,
addVariable,
changeVariableProp,
......@@ -182,8 +186,10 @@ export const {
duplicateVariable,
setCurrentVariableValue,
changeVariableType,
removeInitLock,
resolveInitLock,
variableStateNotStarted,
variableStateFetching,
variableStateCompleted,
variableStateFailed,
} = sharedReducerSlice.actions;
const hasTags = (option: VariableOption): boolean => {
......
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 { NEW_VARIABLE_ID } from '../state/types';
import { Deferred } from '../../../core/utils/deferred';
import { VariablePickerProps } from '../pickers/types';
import { VariableEditorProps } from '../editor/types';
......@@ -12,16 +12,12 @@ export const createSystemVariableAdapter = (): VariableAdapter<SystemVariable<an
description: '',
name: 'system',
initialState: {
id: NEW_VARIABLE_ID,
global: false,
...initialVariableModelState,
type: 'system',
name: '',
label: (null as unknown) as string,
hide: VariableHide.hideVariable,
skipUrlSync: true,
current: { value: { toString: () => '' } },
index: -1,
initLock: (null as unknown) as Deferred,
state: LoadingState.Done,
},
reducer: (state: any, action: any) => state,
picker: (null as unknown) as ComponentType<VariablePickerProps>,
......
import React, { ChangeEvent, FocusEvent, KeyboardEvent, PureComponent } from 'react';
import { TextBoxVariableModel } from '../types';
import { toVariablePayload } from '../state/types';
import { toVariableIdentifier, toVariablePayload } from '../state/types';
import { dispatch } from '../../../store/store';
import { variableAdapters } from '../adapters';
import { changeVariableProp } from '../state/sharedReducer';
import { VariablePickerProps } from '../pickers/types';
import { updateOptions } from '../state/actions';
export interface Props extends VariablePickerProps<TextBoxVariableModel> {}
......@@ -18,13 +18,13 @@ export class TextBoxVariablePicker extends PureComponent<Props> {
onQueryBlur = (event: FocusEvent<HTMLInputElement>) => {
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>) => {
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)));
}
};
......
......@@ -32,7 +32,7 @@ export const createTextBoxVariableAdapter = (): VariableAdapter<TextBoxVariableM
await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable)));
},
getSaveModel: variable => {
const { index, id, initLock, global, ...rest } = cloneDeep(variable);
const { index, id, state, global, ...rest } = cloneDeep(variable);
return rest;
},
getValueForUrl: variable => {
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TextBoxVariableModel, VariableHide, VariableOption } from '../types';
import { getInstanceState, NEW_VARIABLE_ID, VariablePayload } from '../state/types';
import { initialVariableModelState, TextBoxVariableModel, VariableOption } from '../types';
import { getInstanceState, VariablePayload } from '../state/types';
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
export const initialTextBoxVariableModelState: TextBoxVariableModel = {
id: NEW_VARIABLE_ID,
global: false,
index: -1,
...initialVariableModelState,
type: 'textbox',
name: '',
label: '',
hide: VariableHide.dontHide,
query: '',
current: {} as VariableOption,
options: [],
skipUrlSync: false,
initLock: null,
};
export const textBoxVariableSlice = createSlice({
......
import { Deferred } from '../../core/utils/deferred';
import { VariableModel as BaseVariableModel } from '@grafana/data';
import { LoadingState, VariableModel as BaseVariableModel, VariableType } from '@grafana/data';
import { NEW_VARIABLE_ID } from './state/types';
export enum VariableRefresh {
never,
......@@ -125,5 +125,19 @@ export interface VariableModel extends BaseVariableModel {
hide: VariableHide;
skipUrlSync: boolean;
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,
};
......@@ -6,7 +6,7 @@ import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor';
import { CloudWatchDatasource } from '../datasource';
import { CustomVariableModel, VariableHide } from '../../../../features/variables/types';
import { CustomVariableModel, initialVariableModelState } from '../../../../features/variables/types';
const setup = () => {
const instanceSettings = {
......@@ -15,6 +15,7 @@ const setup = () => {
const templateSrv = new TemplateSrv();
const variable: CustomVariableModel = {
...initialVariableModelState,
id: 'var3',
index: 0,
name: 'var3',
......@@ -27,11 +28,7 @@ const setup = () => {
multi: true,
includeAll: false,
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
templateSrv.init([variable]);
......
......@@ -3,26 +3,26 @@ import { CloudWatchDatasource, MAX_ATTEMPTS } from '../datasource';
import * as redux from 'app/store/store';
import {
DataFrame,
DataQueryErrorType,
DataQueryResponse,
DataSourceInstanceSettings,
dateMath,
getFrameDisplayName,
DataQueryErrorType,
} from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import {
CloudWatchLogsQuery,
CloudWatchLogsQueryStatus,
CloudWatchMetricsQuery,
CloudWatchQuery,
LogAction,
CloudWatchLogsQuery,
} from '../types';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { convertToStoreState } from '../../../../../test/helpers/convertToStoreState';
import { getTemplateSrvDependencies } from 'test/helpers/getTemplateSrvDependencies';
import { of, interval } from 'rxjs';
import { CustomVariableModel, VariableHide } from '../../../../features/variables/types';
import { interval, of } from 'rxjs';
import { CustomVariableModel, initialVariableModelState, VariableHide } from '../../../../features/variables/types';
import { TimeSrvStub } from '../../../../../test/specs/helpers';
import * as rxjsUtils from '../utils/rxjs/increasingInterval';
......@@ -376,6 +376,7 @@ describe('CloudWatchDatasource', () => {
it('should generate the correct query with interval variable', async () => {
const period: CustomVariableModel = {
...initialVariableModelState,
id: 'period',
name: 'period',
index: 0,
......@@ -386,9 +387,6 @@ describe('CloudWatchDatasource', () => {
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
templateSrv.init([period]);
......@@ -821,6 +819,7 @@ describe('CloudWatchDatasource', () => {
let requestParams: { queries: CloudWatchMetricsQuery[] };
beforeEach(() => {
const var1: CustomVariableModel = {
...initialVariableModelState,
id: 'var1',
name: 'var1',
index: 0,
......@@ -831,11 +830,9 @@ describe('CloudWatchDatasource', () => {
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
const var2: CustomVariableModel = {
...initialVariableModelState,
id: 'var2',
name: 'var2',
index: 1,
......@@ -846,11 +843,9 @@ describe('CloudWatchDatasource', () => {
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
const var3: CustomVariableModel = {
...initialVariableModelState,
id: 'var3',
name: 'var3',
index: 2,
......@@ -865,11 +860,9 @@ describe('CloudWatchDatasource', () => {
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
const var4: CustomVariableModel = {
...initialVariableModelState,
id: 'var4',
name: 'var4',
index: 3,
......@@ -884,9 +877,6 @@ describe('CloudWatchDatasource', () => {
query: '',
hide: VariableHide.dontHide,
type: 'custom',
label: null,
skipUrlSync: false,
global: false,
};
const variables = [var1, var2, var3, var4];
const state = convertToStoreState(variables);
......
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();
});
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment