mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Templating: Migrates some variable types from Angular to React/Redux (#22434)
* Refactor: Adds variables in state to TemplateSrv * Refactor: Introduces some typings and structures * Refactor: Introduces picker to adapter * Refactor: Removes useState and introduces contains instead * Refactor: Introduces Variable Editor * Refactor: Adds uuid and simplifies state * Refactor: Consolidates to VariableRenderer * Refactor: Adds name change capability * Refactor: Adds variableMiddleware * Refactor: Adds ability to change Angular type to type in State * Fix: Fixes so we check for duplicate names * Refactor: Adds ability to change State type to type in Angular * Refactor: Updates name in dashboard templating list * Refactor: Adds label change to VariableEditor * Refactor: Adds hide change to VariableEditor * Refactor: Adds update ability * Refactor: Adds tooltip * Refactor: Adds SelectionOptionsEditor * Refactor: Adds query editor and validation * Refactor: Adds regex and sort to editor * Refactor: Adds Selection options * Refactor: Adds Varible Values Previewer * Refactor: Changes from array in state to Record * Refactor: Removes getVariableAtIndex from templateSrv * Tests: Fixs broken tests * Chore: Fixes duplicate merge import * Refactor: Removes strict null errors * Refactor: Adds duplicate variable * Refactor: Adds remove variable * Refactor: Adds change order of variables * Refactor: Adds add new variable * Chore: Fixes Prettier formatting * Refactor: Adds VariablePicker * Fixed so sub menu is displayed when we only have redux template variables. * removed unused variable. * tags will be visibile in the new react picker. * added some nice colors to the tags. * Added thunk for selecting a tag. * Refactor: Cleans up templating state when dashboard unloads * Refactor: Adds save capabilities for variables in state * added possibility to select tag. * Added so you can deselect a tag. * Fixed issue with coloring on top. * minor refactoring to make the code more slim. * Refactor: Fixes dispatch return and copy of variable * selecting options when tag i selected * small refactoring. * fixed so we use options. * Refactor: Adds getValueForUrl capabilities * first implementation of keyboard navigation on picker. * removed comment. * fixed so you can toggle all options. * Refactor: Simplified state handling using Redux Toolkit and flat reducer structure * Refactor: Adds sharedTemplatingReducer and queryVariableReducer * Tests: Fixs broken tests * Chore: Removes some strict null errors * Tests: Fix broken tests * Refactor: Splitted QueryVariablePicker into smaller components * Refactor: Moves linktext and selected tags to component instead * Fix: Fixes the ability to have multiple dropdowns opened at same time * Fix: Fixes onKeyDown from prev refactor * Refactor: Adds searchfilter searching * Tests: Fixes after running e2e tests * Refactor: Adds an attempt to solve dependencies at startup * Refactor: Adds feature toggle * Refactor: Resets all angular files to master * Refactor: Move stuff to query folder * Refactor: Initial commit for SubMenu component * Refactor: Updated DashboardModel with new list * Refactor: Adds feature toggle to dashboard model and friends * Refactor: Adds picker to SubMenu * Refactor: Fixes styling on SubMenu * Refactor: Fixes processvariables * Refactor: Initial EditorList skeleton * Refactor: Refactors out VariableEditorList and VariableEditorContainer * Refactor: Adds New variable functionality * Refactor: Adds registred types * Refactor: Adds edit existing variable functionality * Refactor: Changes params to thunks * Refactor: Small fix for cleaning up state when clicking update/add * Refactor: Better typings for outer containers * Refactor: Adds change order functionality * Refactor: Removed notify angular args * Change so the url is in sync with the redux template variables. * Adding support for saving proper values and checking changes. * Refactor: Adds duplicate variable functionality * Feature: Adds remove variable functionality * Refactor: Small refactor so e2e tests work as before * Refactor: Returns null if no visible variables * Refactor: Adds annotations to SubMenu * Refactor: Fixes toggling of annotations in SubMenu * added dashboard links to new submehu. * Refactor: Small refactor breaking up into smaller components * Fix: Fixes infinite recursive loop when changing varible name * Templating: Do not mutate location query state * Refactor: Fixes minor timing issue when adding new variable * Refactor: removes initialization in variable_srv constructor * Refactor: Suggestion on how to handle templating.list in DashboardExporter * Refactor: Adds getVariables typings and changes ChangeTracker and ShareSnapshotCtrl * Refactor: Adds getVariable on DashboardModel and changes DashboardMigrator * Fix: Fixes repeated panels * wip: starting to add custom variable type. * Refactor: Merging two different toVariablePayload functions * Tests: Fixes broken tests * Fix: Reduces strict null errors * Tests: Initial commit and fixes strange dependency order * Tests: Covers sharedTemplatingReducer with tests * Refactor: Rename state/index.ts => state/reducers.ts as every where else * Refactor: Renames and moves adapters.ts * Tests: Adds tests for templatingReducer * Tests: Adds intitial tests for queryVariableReducer * starting to ad custom variable. * Tests: Adds more queryVariableReducer tests * Added support for custom variable. Next up applying some DRY principles and refactoring. * fixed compile issue. * added todo. * Tests: Fixes broken test * Tests: Covers queryVariableReducer with tests and fixed a couple of bugs * Fix: Fixes broken test * Fix: Reduces strict null errors * change so custom won't be depending on anything elese. * fixed descriptions. * removed unused dependency. * Fixed issue when adding a new variable and editor is being unmount twice. * fixed issue with select option loop. * changed so we update query on typing in editor and removed it from component state. * Moved runQuery up one level in the component tree. * renamed action and moved it to custom actions. * moved applyStateChanges to shared code. * removed todo comment. * first stab on moving picker to more general. * Refactor: Changes so we always show variables type * removed duplicate code regarding picker. * Did some renamings. * Feature: Adds text box variable type * moved tests from query reducer to picker reducer. * Removed picker from VariableState. * removed reference to picker. * Some more refactorings of the picker reducer + actions. * Chore: Refactors away editor state to its own state slice (#22515) * Refactor: Inital move, tests not working * Tests: Adds editorReducer tests * Refactor: Cleaning up * Refactor: Moves logic to thunk instead * Refactor: Initial commit * Refactor: Combines reducers to one state * Refactor: Adds combine reducers * moved navigation logic flow to a thunk instead of in the component. * fixed issue with rendering picker link. * Refactor: Removes variable prop from templating.variables * refactored and removed some more code. * Feature: adds Constant variable type * fixed so tags can be selected. * Fix: fixes default hide for constant and enum order * fixed so tags works again. * Fix: fixes so we use Angular editor when newVariables is not defined * Fix: fixes wrong hide default for Constant variable * Fix: fixes bug when using duplicate button * Fix: changes action id * Tests: prepares for newVariables * Chore: reduces strict null errors * Refactor: removes uuidInEditorReducer for simplification * Chore: changes after PR comments * Chore: uses getConfig instead of config * Tests: fixes so e2e tests check for feature toggle and fixed initLock bug * Refactor: changes so sharedReducer uses createSlice instead * Refactor: changes textBoxVariableReducer to use creactSlice instead * Refactor: changes queryBoxVariableReducer to use creactSlice instead * Refactor: changes customVariableReducer to use creactSlice instead * Refactor: changes constantVariableReducer to use creactSlice instead * Refactor: moves types to specific types.ts files instead * changed so we use queryValue stored on the variable to populate options input when opening picker. * Feature: adds the ability to test templating thunks using real store and middleware * Chore: cleans up unused import * excluded queryValue from the getSaveModel * Refactor: adds whenAsyncActionIsDispatched to reduxTester * Tests: adds initial tests for processVariables * Added reducer tests for constant variable. * added tests for custom reducer. * added tets for texbox reducer. * Tests: adds more tests for ProcessVariable * Refactor: fixes processVariable flow so we do notrun updateOptions twice * Tests: finishes tests for processVariables and removed skip test * added actions tests for custom and constant. * Tests: adds tests for setOptionFromUrl * Tests: adds a naive variable mock builder * Tests: adds tests for validateVariableSelectionState * added tests for query variable actions. * added last test for query actions. * added more tests. * some more tests. * fixed typing errors. * Fixed issues with variable tags. Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
@@ -212,6 +212,7 @@
|
||||
"@types/md5": "^2.1.33",
|
||||
"@types/react-loadable": "5.5.2",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.0",
|
||||
"@types/uuid": "3.4.7",
|
||||
"abortcontroller-polyfill": "1.4.0",
|
||||
"angular": "1.6.9",
|
||||
"angular-bindonce": "0.3.1",
|
||||
@@ -274,6 +275,7 @@
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "1.4.1",
|
||||
"tti-polyfill": "0.2.2",
|
||||
"uuid": "3.4.0",
|
||||
"whatwg-fetch": "3.0.0",
|
||||
"xss": "1.0.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import extend from 'lodash/extend';
|
||||
import { getTheme } from '@grafana/ui';
|
||||
import { GrafanaTheme, GrafanaThemeType, PanelPluginMeta, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme, GrafanaThemeType, PanelPluginMeta } from '@grafana/data';
|
||||
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
@@ -18,6 +18,7 @@ interface FeatureToggles {
|
||||
expressions: boolean;
|
||||
newEdit: boolean;
|
||||
meta: boolean;
|
||||
newVariables: boolean;
|
||||
}
|
||||
|
||||
interface LicenseInfo {
|
||||
@@ -69,6 +70,7 @@ export class GrafanaBootConfig {
|
||||
expressions: false,
|
||||
newEdit: false,
|
||||
meta: false,
|
||||
newVariables: false,
|
||||
};
|
||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||
phantomJSRenderer = false;
|
||||
|
||||
@@ -10,12 +10,12 @@ import { MetricSelect } from './components/Select/MetricSelect';
|
||||
import AppNotificationList from './components/AppNotifications/AppNotificationList';
|
||||
import {
|
||||
ColorPicker,
|
||||
SeriesColorPickerPopoverWithTheme,
|
||||
SecretFormField,
|
||||
UnitPicker,
|
||||
DataLinksEditor,
|
||||
DataSourceHttpSettings,
|
||||
GraphContextMenu,
|
||||
SecretFormField,
|
||||
SeriesColorPickerPopoverWithTheme,
|
||||
UnitPicker,
|
||||
} from '@grafana/ui';
|
||||
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
|
||||
import { SearchField } from './components/search/SearchField';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SaveDashboardAsButtonConnected,
|
||||
SaveDashboardButtonConnected,
|
||||
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
|
||||
import { VariableEditorContainer } from '../features/templating/editor/VariableEditorContainer';
|
||||
|
||||
export function registerAngularDirectives() {
|
||||
react2AngularDirective('footer', Footer, []);
|
||||
@@ -165,4 +166,5 @@ export function registerAngularDirectives() {
|
||||
['getDashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||
['onSaveSuccess', { watchDepth: 'reference', wrapApply: true }],
|
||||
]);
|
||||
react2AngularDirective('variableEditorContainer', VariableEditorContainer, []);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { LoadingChunkPlaceHolder } from './LoadingChunkPlaceHolder';
|
||||
describe('loadComponentHandler', () => {
|
||||
describe('when there is no error and pastDelay is false', () => {
|
||||
it('then it should return null', () => {
|
||||
const error: Error = null;
|
||||
const error: Error | null = null;
|
||||
const pastDelay = false;
|
||||
const element = loadComponentHandler({ error, pastDelay });
|
||||
const element = loadComponentHandler({ error: (error as unknown) as Error, pastDelay });
|
||||
|
||||
expect(element).toBe(null);
|
||||
});
|
||||
@@ -26,9 +26,9 @@ describe('loadComponentHandler', () => {
|
||||
|
||||
describe('when loading is taking more then default delay of 200ms', () => {
|
||||
it('then it should return LoadingChunkPlaceHolder', () => {
|
||||
const error: Error = null;
|
||||
const error: Error | null = null;
|
||||
const pastDelay = true;
|
||||
const element = loadComponentHandler({ error, pastDelay });
|
||||
const element = loadComponentHandler({ error: (error as unknown) as Error, pastDelay });
|
||||
|
||||
expect(element).toEqual(<LoadingChunkPlaceHolder />);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('rootReducer', () => {
|
||||
} as StoreState;
|
||||
|
||||
reducerTester<StoreState>()
|
||||
.givenReducer(rootReducer, state, true)
|
||||
.givenReducer(rootReducer, state, false, true)
|
||||
.whenActionIsDispatched(cleanUpAction({ stateSelector: (storeState: StoreState) => storeState.teams }))
|
||||
.thenStatePredicateShouldEqual(resultingState => {
|
||||
expect(resultingState.teams).toEqual({ ...initialTeamsState });
|
||||
|
||||
@@ -13,6 +13,7 @@ import usersReducers from 'app/features/users/state/reducers';
|
||||
import userReducers from 'app/features/profile/state/reducers';
|
||||
import organizationReducers from 'app/features/org/state/reducers';
|
||||
import ldapReducers from 'app/features/admin/state/reducers';
|
||||
import templatingReducers from 'app/features/templating/state/reducers';
|
||||
|
||||
const rootReducers = {
|
||||
...sharedReducers,
|
||||
@@ -28,6 +29,7 @@ const rootReducers = {
|
||||
...userReducers,
|
||||
...organizationReducers,
|
||||
...ldapReducers,
|
||||
...templatingReducers,
|
||||
};
|
||||
|
||||
const addedReducers = {};
|
||||
|
||||
@@ -8,6 +8,7 @@ jest.mock('app/core/store', () => {
|
||||
return {
|
||||
getBool: jest.fn(),
|
||||
set: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
7
public/app/core/utils/applyStateChanges.ts
Normal file
7
public/app/core/utils/applyStateChanges.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type MutateStateFunc<S> = (state: S) => S;
|
||||
|
||||
export const applyStateChanges = <S>(state: S, ...args: Array<MutateStateFunc<S>>): S => {
|
||||
return args.reduce((all, cur) => {
|
||||
return cur(all);
|
||||
}, state);
|
||||
};
|
||||
14
public/app/core/utils/deferred.ts
Normal file
14
public/app/core/utils/deferred.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export class Deferred {
|
||||
resolve: any;
|
||||
reject: any;
|
||||
promise: Promise<any>;
|
||||
constructor() {
|
||||
this.resolve = null;
|
||||
this.reject = null;
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
this.resolve = resolve;
|
||||
this.reject = reject;
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { PanelPluginMeta } from '@grafana/data';
|
||||
jest.mock('app/core/store', () => {
|
||||
return {
|
||||
getBool: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -17,6 +18,9 @@ jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
buildInfo: {},
|
||||
panels: {},
|
||||
featureToggles: {
|
||||
newVariables: false,
|
||||
},
|
||||
},
|
||||
DataSourceWithBackend: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -54,7 +54,7 @@ export class DashboardExporter {
|
||||
const promises: Array<Promise<void>> = [];
|
||||
const variableLookup: { [key: string]: any } = {};
|
||||
|
||||
for (const variable of saveModel.templating.list) {
|
||||
for (const variable of saveModel.getVariables()) {
|
||||
variableLookup[variable.name] = variable;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export class DashboardExporter {
|
||||
}
|
||||
|
||||
// templatize template vars
|
||||
for (const variable of saveModel.templating.list) {
|
||||
for (const variable of saveModel.getVariables()) {
|
||||
if (variable.type === 'query') {
|
||||
templateizeDatasourceUsage(variable);
|
||||
variable.options = [];
|
||||
@@ -172,7 +172,7 @@ export class DashboardExporter {
|
||||
});
|
||||
|
||||
// templatize constants
|
||||
for (const variable of saveModel.templating.list) {
|
||||
for (const variable of saveModel.getVariables()) {
|
||||
if (variable.type === 'constant') {
|
||||
const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase();
|
||||
inputs.push({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { e2e } from '@grafana/e2e';
|
||||
|
||||
import { appEvents, contextSrv, coreModule } from 'app/core/core';
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import config from 'app/core/config';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { DashboardSrv } from '../../services/DashboardSrv';
|
||||
import { CoreEvents } from 'app/types';
|
||||
@@ -26,6 +26,7 @@ export class SettingsCtrl {
|
||||
sections: any[];
|
||||
hasUnsavedFolderChange: boolean;
|
||||
selectors: typeof e2e.pages.Dashboard.Settings.General.selectors;
|
||||
useAngularTemplating: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
@@ -60,6 +61,7 @@ export class SettingsCtrl {
|
||||
appEvents.on(CoreEvents.dashboardSaved, this.onPostSave.bind(this), $scope);
|
||||
|
||||
this.selectors = e2e.pages.Dashboard.Settings.General.selectors;
|
||||
this.useAngularTemplating = !getConfig().featureToggles.newVariables;
|
||||
}
|
||||
|
||||
buildSectionList() {
|
||||
@@ -123,7 +125,7 @@ export class SettingsCtrl {
|
||||
|
||||
for (const section of this.sections) {
|
||||
const sectionParams = _.defaults({ editview: section.id }, params);
|
||||
section.url = config.appSubUrl + url + '?' + $.param(sectionParams);
|
||||
section.url = getConfig().appSubUrl + url + '?' + $.param(sectionParams);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,10 +77,14 @@
|
||||
ng-include="'public/app/features/annotations/partials/editor.html'">
|
||||
</div>
|
||||
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'templating'"
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'templating' && ctrl.useAngularTemplating"
|
||||
ng-include="'public/app/features/templating/partials/editor.html'">
|
||||
</div>
|
||||
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'templating' && !ctrl.useAngularTemplating">
|
||||
<variable-editor-container />
|
||||
</div>
|
||||
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'links'">
|
||||
<dash-links-editor dashboard="ctrl.dashboard"></dash-links-editor>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { thunkTester } from '../../../../../../test/core/thunk/thunkTester';
|
||||
import { initialState } from './reducers';
|
||||
import { closeCompleted, initialState, PanelEditorStateNew } from './reducers';
|
||||
import { initPanelEditor, panelEditorCleanUp } from './actions';
|
||||
import { PanelEditorStateNew, closeCompleted } from './reducers';
|
||||
import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers';
|
||||
import { PanelModel, DashboardModel } from '../../../state';
|
||||
import { DashboardModel, PanelModel } from '../../../state';
|
||||
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
|
||||
|
||||
describe('panelEditor actions', () => {
|
||||
@@ -38,7 +37,7 @@ describe('panelEditor actions', () => {
|
||||
panel.updateOptions({ prop: true });
|
||||
|
||||
const state: PanelEditorStateNew = {
|
||||
...initialState,
|
||||
...initialState(),
|
||||
getPanel: () => panel,
|
||||
getSourcePanel: () => sourcePanel,
|
||||
querySubscription: { unsubscribe: jest.fn() },
|
||||
@@ -72,7 +71,7 @@ describe('panelEditor actions', () => {
|
||||
panel.updateOptions({ prop: true });
|
||||
|
||||
const state: PanelEditorStateNew = {
|
||||
...initialState,
|
||||
...initialState(),
|
||||
getPanel: () => panel,
|
||||
getSourcePanel: () => sourcePanel,
|
||||
querySubscription: { unsubscribe: jest.fn() },
|
||||
@@ -101,7 +100,7 @@ describe('panelEditor actions', () => {
|
||||
panel.updateOptions({ prop: true });
|
||||
|
||||
const state: PanelEditorStateNew = {
|
||||
...initialState,
|
||||
...initialState(),
|
||||
shouldDiscardChanges: true,
|
||||
getPanel: () => panel,
|
||||
getSourcePanel: () => sourcePanel,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { PanelModel } from '../../../state/PanelModel';
|
||||
import { PanelData, LoadingState, DefaultTimeRange } from '@grafana/data';
|
||||
import { DefaultTimeRange, LoadingState, PanelData } from '@grafana/data';
|
||||
import { DisplayMode } from '../types';
|
||||
import store from '../../../../../core/store';
|
||||
|
||||
@@ -38,21 +38,23 @@ export interface PanelEditorStateNew {
|
||||
ui: PanelEditorUIState;
|
||||
}
|
||||
|
||||
export const initialState: PanelEditorStateNew = {
|
||||
getPanel: () => new PanelModel({}),
|
||||
getSourcePanel: () => new PanelModel({}),
|
||||
getData: () => ({
|
||||
state: LoadingState.NotStarted,
|
||||
series: [],
|
||||
timeRange: DefaultTimeRange,
|
||||
}),
|
||||
initDone: false,
|
||||
shouldDiscardChanges: false,
|
||||
isOpen: false,
|
||||
ui: {
|
||||
...DEFAULT_PANEL_EDITOR_UI_STATE,
|
||||
...store.getObject(PANEL_EDITOR_UI_STATE_STORAGE_KEY, DEFAULT_PANEL_EDITOR_UI_STATE),
|
||||
},
|
||||
export const initialState = (): PanelEditorStateNew => {
|
||||
return {
|
||||
getPanel: () => new PanelModel({}),
|
||||
getSourcePanel: () => new PanelModel({}),
|
||||
getData: () => ({
|
||||
state: LoadingState.NotStarted,
|
||||
series: [],
|
||||
timeRange: DefaultTimeRange,
|
||||
}),
|
||||
initDone: false,
|
||||
shouldDiscardChanges: false,
|
||||
isOpen: false,
|
||||
ui: {
|
||||
...DEFAULT_PANEL_EDITOR_UI_STATE,
|
||||
...store.getObject(PANEL_EDITOR_UI_STATE_STORAGE_KEY, DEFAULT_PANEL_EDITOR_UI_STATE),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface InitEditorPayload {
|
||||
@@ -63,7 +65,7 @@ interface InitEditorPayload {
|
||||
|
||||
const pluginsSlice = createSlice({
|
||||
name: 'panelEditorNew',
|
||||
initialState,
|
||||
initialState: initialState(),
|
||||
reducers: {
|
||||
updateEditorInitState: (state, action: PayloadAction<InitEditorPayload>) => {
|
||||
state.getPanel = () => action.payload.panel;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Forms, HorizontalGroup, Button } from '@grafana/ui';
|
||||
import { Button, Forms, HorizontalGroup } from '@grafana/ui';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { SaveDashboardFormProps } from '../types';
|
||||
@@ -13,7 +13,7 @@ interface SaveDashboardAsFormDTO {
|
||||
}
|
||||
|
||||
const getSaveAsDashboardClone = (dashboard: DashboardModel) => {
|
||||
const clone = dashboard.getSaveModelClone();
|
||||
const clone: any = dashboard.getSaveModelClone();
|
||||
clone.id = null;
|
||||
clone.uid = '';
|
||||
clone.title += ' Copy';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { Button, Select, LinkButton, Input, ClipboardButton } from '@grafana/ui';
|
||||
import { SelectableValue, AppEvents } from '@grafana/data';
|
||||
import { Button, ClipboardButton, Input, LinkButton, Select } from '@grafana/ui';
|
||||
import { AppEvents, SelectableValue } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
@@ -139,7 +139,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
|
||||
});
|
||||
|
||||
// remove template queries
|
||||
dash.templating.list.forEach(variable => {
|
||||
dash.getVariables().forEach(variable => {
|
||||
variable.query = '';
|
||||
variable.options = variable.current;
|
||||
variable.refresh = false;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
export class AngularDashboardLinks extends PureComponent<Props> {
|
||||
element: HTMLElement;
|
||||
angularCmp: AngularComponent;
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.hasLinks()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<dash-links-container dashboard="dashboard" links="links" class="gf-form-inline" />';
|
||||
const scopeProps = {
|
||||
dashboard: this.props.dashboard,
|
||||
links: this.props.dashboard.links,
|
||||
};
|
||||
|
||||
this.angularCmp = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.angularCmp) {
|
||||
this.angularCmp.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
hasLinks = () => this.props.dashboard.links.length > 0;
|
||||
|
||||
render() {
|
||||
if (!this.hasLinks()) {
|
||||
return null;
|
||||
}
|
||||
return <div ref={element => (this.element = element)} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Libaries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Utils & Services
|
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
|
||||
|
||||
// Types
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
export class AngularSubMenu extends PureComponent<Props> {
|
||||
element: HTMLElement;
|
||||
angularCmp: AngularComponent;
|
||||
|
||||
componentDidMount() {
|
||||
const loader = getAngularLoader();
|
||||
|
||||
const template = '<dashboard-submenu dashboard="dashboard" />';
|
||||
const scopeProps = { dashboard: this.props.dashboard };
|
||||
|
||||
this.angularCmp = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.angularCmp) {
|
||||
this.angularCmp.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div ref={element => (this.element = element)} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { Switch } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
annotations: any[];
|
||||
onAnnotationChanged: (annotation: any) => void;
|
||||
}
|
||||
|
||||
export const Annotations: FunctionComponent<Props> = ({ annotations, onAnnotationChanged }) => {
|
||||
const [visibleAnnotations, setVisibleAnnotations] = useState([]);
|
||||
useEffect(() => {
|
||||
setVisibleAnnotations(annotations.filter(annotation => annotation.hide !== true));
|
||||
}, [annotations]);
|
||||
|
||||
if (visibleAnnotations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleAnnotations.map(annotation => {
|
||||
return (
|
||||
<div
|
||||
key={annotation.name}
|
||||
className={annotation.enable ? 'submenu-item' : 'submenu-item annotation-disabled'}
|
||||
>
|
||||
<Switch
|
||||
label={annotation.name}
|
||||
className="gf-form"
|
||||
checked={annotation.enable}
|
||||
onChange={() => onAnnotationChanged(annotation)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,76 @@
|
||||
// Libaries
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect, MapStateToProps } from 'react-redux';
|
||||
import { StoreState } from '../../../../types';
|
||||
import { getVariableClones } from '../../../templating/state/selectors';
|
||||
import { VariableHide, VariableModel } from '../../../templating/variable';
|
||||
import { DashboardModel } from '../../state';
|
||||
import { AngularDashboardLinks } from './AngularDashboardLinks';
|
||||
import { Annotations } from './Annotations';
|
||||
import { SubMenuItems } from './SubMenuItems';
|
||||
|
||||
// Utils & Services
|
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
|
||||
|
||||
// Types
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardModel | null;
|
||||
interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
|
||||
export class SubMenu extends PureComponent<Props> {
|
||||
element: HTMLElement;
|
||||
angularCmp: AngularComponent;
|
||||
interface ConnectedProps {
|
||||
variables: VariableModel[];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const loader = getAngularLoader();
|
||||
interface DispatchProps {}
|
||||
|
||||
const template = '<dashboard-submenu dashboard="dashboard" />';
|
||||
const scopeProps = { dashboard: this.props.dashboard };
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
this.angularCmp = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.angularCmp) {
|
||||
this.angularCmp.destroy();
|
||||
class SubMenuUnConnected extends PureComponent<Props> {
|
||||
onAnnotationStateChanged = (updatedAnnotation: any) => {
|
||||
// we're mutating dashboard state directly here until annotations are in Redux.
|
||||
for (let index = 0; index < this.props.dashboard.annotations.list.length; index++) {
|
||||
const annotation = this.props.dashboard.annotations.list[index];
|
||||
if (annotation.name === updatedAnnotation.name) {
|
||||
annotation.enable = !annotation.enable;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.props.dashboard.startRefresh();
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
isSubMenuVisible = () => {
|
||||
if (this.props.dashboard.links.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const visibleVariables = this.props.variables.filter(variable => variable.hide !== VariableHide.hideVariable);
|
||||
if (visibleVariables.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const visibleAnnotations = this.props.dashboard.annotations.list.filter(annotation => annotation.hide !== true);
|
||||
return visibleAnnotations.length > 0;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div ref={element => (this.element = element)} />;
|
||||
if (!this.isSubMenuVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="submenu-controls">
|
||||
<SubMenuItems variables={this.props.variables} />
|
||||
<Annotations
|
||||
annotations={this.props.dashboard.annotations.list}
|
||||
onAnnotationChanged={this.onAnnotationStateChanged}
|
||||
/>
|
||||
<div className="gf-form gf-form--grow" />
|
||||
<AngularDashboardLinks dashboard={this.props.dashboard} />
|
||||
<div className="clearfix" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
|
||||
variables: getVariableClones(state, false),
|
||||
});
|
||||
|
||||
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
|
||||
SubMenu.displayName = 'SubMenu';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { VariableHide, VariableModel } from '../../../templating/variable';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { PickerRenderer } from '../../../templating/pickers/PickerRenderer';
|
||||
|
||||
interface Props {
|
||||
variables: VariableModel[];
|
||||
}
|
||||
|
||||
export const SubMenuItems: FunctionComponent<Props> = ({ variables }) => {
|
||||
const [visibleVariables, setVisibleVariables] = useState<VariableModel[]>([]);
|
||||
useEffect(() => {
|
||||
setVisibleVariables(variables.filter(state => state.hide !== VariableHide.hideVariable));
|
||||
}, [variables]);
|
||||
|
||||
if (visibleVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleVariables.map(variable => {
|
||||
return (
|
||||
<div
|
||||
key={variable.uuid}
|
||||
className="submenu-item gf-form-inline"
|
||||
aria-label={e2e.pages.Dashboard.SubMenu.selectors.submenuItem}
|
||||
>
|
||||
<PickerRenderer variable={variable} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
export { SubMenuCtrl } from './SubMenuCtrl';
|
||||
export { SubMenu } from './SubMenu';
|
||||
export { AngularSubMenu } from './AngularSubMenu';
|
||||
|
||||
@@ -12,11 +12,10 @@ import { Branding } from 'app/core/components/Branding/Branding';
|
||||
// Components
|
||||
import { DashboardGrid } from '../dashgrid/DashboardGrid';
|
||||
import { DashNav } from '../components/DashNav';
|
||||
import { SubMenu } from '../components/SubMenu';
|
||||
import { AngularSubMenu } from '../components/SubMenu';
|
||||
import { DashboardSettings } from '../components/DashboardSettings';
|
||||
import { PanelEditor } from '../components/PanelEditor/PanelEditor';
|
||||
import { CustomScrollbar, Alert, Portal } from '@grafana/ui';
|
||||
|
||||
import { Alert, CustomScrollbar, Portal } from '@grafana/ui';
|
||||
// Redux
|
||||
import { initDashboard } from '../state/initDashboard';
|
||||
import { cleanUpDashboard } from '../state/reducers';
|
||||
@@ -32,6 +31,8 @@ import {
|
||||
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { InspectTab, PanelInspector } from '../components/Inspector/PanelInspector';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { SubMenu } from '../components/SubMenu/SubMenu';
|
||||
|
||||
export interface Props {
|
||||
urlUid?: string;
|
||||
@@ -315,7 +316,8 @@ export class DashboardPage extends PureComponent<Props, State> {
|
||||
{initError && this.renderInitFailedState()}
|
||||
|
||||
<div className={gridWrapperClasses}>
|
||||
<SubMenu dashboard={dashboard} />
|
||||
{!getConfig().featureToggles.newVariables && <AngularSubMenu dashboard={dashboard} />}
|
||||
{getConfig().featureToggles.newVariables && <SubMenu dashboard={dashboard} />}
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -30,6 +30,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -94,6 +95,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -118,7 +122,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
<div
|
||||
className="dashboard-container"
|
||||
>
|
||||
<SubMenu
|
||||
<AngularSubMenu
|
||||
dashboard={
|
||||
DashboardModel {
|
||||
"annotations": Object {
|
||||
@@ -143,6 +147,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -207,6 +212,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -236,6 +244,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -300,6 +309,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -361,6 +373,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -425,6 +438,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -472,6 +488,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -536,6 +553,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -543,7 +563,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
<div
|
||||
className="dashboard-container"
|
||||
>
|
||||
<SubMenu
|
||||
<AngularSubMenu
|
||||
dashboard={
|
||||
DashboardModel {
|
||||
"annotations": Object {
|
||||
@@ -568,6 +588,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -632,6 +653,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -661,6 +685,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"_eventsCount": 0,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -725,6 +750,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"_eventsCount": 6,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -251,6 +252,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -347,6 +351,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"_eventsCount": 6,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -493,6 +498,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -589,6 +597,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"_eventsCount": 6,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -735,6 +744,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
@@ -831,6 +843,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"_eventsCount": 6,
|
||||
},
|
||||
},
|
||||
"getVariables": [Function],
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
@@ -977,6 +990,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
"timezone": "",
|
||||
"title": "My dashboard",
|
||||
"uid": null,
|
||||
"variables": Object {
|
||||
"list": Array [],
|
||||
},
|
||||
"version": 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import _ from 'lodash';
|
||||
import { DashboardModel } from '../state/DashboardModel';
|
||||
import { ContextSrv } from 'app/core/services/context_srv';
|
||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||
import { CoreEvents, AppEventConsumer } from 'app/types';
|
||||
import { AppEventConsumer, CoreEvents } from 'app/types';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
|
||||
export class ChangeTracker {
|
||||
@@ -132,10 +132,10 @@ export class ChangeTracker {
|
||||
});
|
||||
|
||||
// ignore template variable values
|
||||
_.each(dash.templating.list, value => {
|
||||
value.current = null;
|
||||
value.options = null;
|
||||
value.filters = null;
|
||||
_.each(dash.getVariables(), variable => {
|
||||
variable.current = null;
|
||||
variable.options = null;
|
||||
variable.filters = null;
|
||||
});
|
||||
|
||||
return dash;
|
||||
@@ -145,8 +145,8 @@ export class ChangeTracker {
|
||||
const current = this.cleanDashboardFromIgnoredChanges(this.current.getSaveModelClone());
|
||||
const original = this.cleanDashboardFromIgnoredChanges(this.original);
|
||||
|
||||
const currentTimepicker: any = _.find(current.nav, { type: 'timepicker' });
|
||||
const originalTimepicker: any = _.find(original.nav, { type: 'timepicker' });
|
||||
const currentTimepicker: any = _.find((current as any).nav, { type: 'timepicker' });
|
||||
const originalTimepicker: any = _.find((original as any).nav, { type: 'timepicker' });
|
||||
|
||||
if (currentTimepicker && originalTimepicker) {
|
||||
currentTimepicker.now = originalTimepicker.now;
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
|
||||
// Utils
|
||||
import getFactors from 'app/core/utils/factors';
|
||||
import { appendQueryToUrl } from 'app/core/utils/url';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from './PanelModel';
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
import { DataLink } from '@grafana/data';
|
||||
|
||||
// Constants
|
||||
import {
|
||||
GRID_COLUMN_COUNT,
|
||||
DEFAULT_PANEL_SPAN,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
GRID_CELL_HEIGHT,
|
||||
GRID_CELL_VMARGIN,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
GRID_COLUMN_COUNT,
|
||||
MIN_PANEL_HEIGHT,
|
||||
DEFAULT_PANEL_SPAN,
|
||||
} from 'app/core/constants';
|
||||
import { DataLinkBuiltInVars } from '@grafana/ui';
|
||||
|
||||
@@ -128,8 +125,9 @@ export class DashboardMigrator {
|
||||
}
|
||||
|
||||
// update template variables
|
||||
for (i = 0; i < this.dashboard.templating.list.length; i++) {
|
||||
const variable = this.dashboard.templating.list[i];
|
||||
const variables = this.dashboard.getVariables();
|
||||
for (i = 0; i < variables.length; i++) {
|
||||
const variable = variables[i];
|
||||
if (variable.datasource === void 0) {
|
||||
variable.datasource = null;
|
||||
}
|
||||
@@ -242,7 +240,7 @@ export class DashboardMigrator {
|
||||
|
||||
if (oldVersion < 12) {
|
||||
// update template variables
|
||||
_.each(this.dashboard.templating.list, templateVariable => {
|
||||
_.each(this.dashboard.getVariables(), templateVariable => {
|
||||
if (templateVariable.refresh) {
|
||||
templateVariable.refresh = 1;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import { DashboardMigrator } from './DashboardMigrator';
|
||||
import { AppEvent, dateTime, DateTimeInput, isDateTime, PanelEvents, TimeRange, TimeZone, toUtc } from '@grafana/data';
|
||||
import { UrlQueryValue } from '@grafana/runtime';
|
||||
import { CoreEvents, DashboardMeta, KIOSK_MODE_TV } from 'app/types';
|
||||
import { VariableModel } from '../../templating/variable';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { getVariables } from 'app/features/templating/state/selectors';
|
||||
import { variableAdapters } from 'app/features/templating/adapters';
|
||||
|
||||
export interface CloneOptions {
|
||||
saveVariables?: boolean;
|
||||
@@ -35,6 +39,7 @@ export class DashboardModel {
|
||||
private originalTime: any;
|
||||
timepicker: any;
|
||||
templating: { list: any[] };
|
||||
variables: { list: VariableModel[] };
|
||||
private originalTemplating: any;
|
||||
annotations: { list: any[] };
|
||||
refresh: any;
|
||||
@@ -86,6 +91,7 @@ export class DashboardModel {
|
||||
this.time = data.time || { from: 'now-6h', to: 'now' };
|
||||
this.timepicker = data.timepicker || {};
|
||||
this.templating = this.ensureListExist(data.templating);
|
||||
this.variables = this.ensureListExist(data.variables);
|
||||
this.annotations = this.ensureListExist(data.annotations);
|
||||
this.refresh = data.refresh;
|
||||
this.snapshot = data.snapshot;
|
||||
@@ -95,7 +101,7 @@ export class DashboardModel {
|
||||
this.gnetId = data.gnetId || null;
|
||||
this.panels = _.map(data.panels || [], (panelData: any) => new PanelModel(panelData));
|
||||
|
||||
this.resetOriginalVariables();
|
||||
this.resetOriginalVariables(true);
|
||||
this.resetOriginalTime();
|
||||
|
||||
this.initMeta(meta);
|
||||
@@ -151,7 +157,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
// cleans meta data and other non persistent state
|
||||
getSaveModelClone(options?: CloneOptions) {
|
||||
getSaveModelClone(options?: CloneOptions): DashboardModel {
|
||||
const defaults = _.defaults(options || {}, {
|
||||
saveVariables: true,
|
||||
saveTimerange: true,
|
||||
@@ -167,6 +173,44 @@ export class DashboardModel {
|
||||
copy[property] = _.cloneDeep(this[property]);
|
||||
}
|
||||
|
||||
this.updateTemplatingSaveModelClone(copy, defaults);
|
||||
|
||||
if (!defaults.saveTimerange) {
|
||||
copy.time = this.originalTime;
|
||||
}
|
||||
|
||||
// get panel save models
|
||||
copy.panels = _.chain(this.panels)
|
||||
.filter((panel: PanelModel) => panel.type !== 'add-panel')
|
||||
.map((panel: PanelModel) => panel.getSaveModel())
|
||||
.value();
|
||||
|
||||
// sort by keys
|
||||
copy = sortByKeys(copy);
|
||||
copy.getVariables = () => {
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
return copy.variables.list;
|
||||
}
|
||||
return copy.templating.list;
|
||||
};
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
private updateTemplatingSaveModelClone(
|
||||
copy: any,
|
||||
defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions
|
||||
) {
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
this.updateTemplatingSaveModel(copy, defaults);
|
||||
}
|
||||
this.updateAngularTemplatingSaveModel(copy, defaults);
|
||||
}
|
||||
|
||||
private updateAngularTemplatingSaveModel(
|
||||
copy: any,
|
||||
defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions
|
||||
) {
|
||||
// get variable save models
|
||||
copy.templating = {
|
||||
list: _.map(this.templating.list, (variable: any) =>
|
||||
@@ -190,21 +234,35 @@ export class DashboardModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaults.saveTimerange) {
|
||||
copy.time = this.originalTime;
|
||||
private updateTemplatingSaveModel(
|
||||
copy: any,
|
||||
defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions
|
||||
) {
|
||||
const originalVariables = this.variables.list;
|
||||
const currentVariables = getVariables();
|
||||
|
||||
copy.variables = {
|
||||
list: currentVariables.map(variable => variableAdapters.get(variable.type).getSaveModel(variable)),
|
||||
};
|
||||
|
||||
if (!defaults.saveVariables) {
|
||||
for (let i = 0; i < copy.variables.list.length; i++) {
|
||||
const current = copy.variables.list[i];
|
||||
const original: any = _.find(originalVariables, { name: current.name, type: current.type });
|
||||
|
||||
if (!original) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.type === 'adhoc') {
|
||||
copy.variables.list[i].filters = original.filters;
|
||||
} else {
|
||||
copy.variables.list[i].current = original.current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get panel save models
|
||||
copy.panels = _.chain(this.panels)
|
||||
.filter((panel: PanelModel) => panel.type !== 'add-panel')
|
||||
.map((panel: PanelModel) => panel.getSaveModel())
|
||||
.value();
|
||||
|
||||
// sort by keys
|
||||
copy = sortByKeys(copy);
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
setViewMode(panel: PanelModel, fullscreen: boolean, isEditing: boolean) {
|
||||
@@ -346,7 +404,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
cleanUpRepeats() {
|
||||
if (this.snapshot || this.templating.list.length === 0) {
|
||||
if (this.isSnapshotTruthy() || !this.hasVariables()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,7 +431,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
processRepeats() {
|
||||
if (this.snapshot || this.templating.list.length === 0) {
|
||||
if (this.isSnapshotTruthy() || !this.hasVariables()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -405,7 +463,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
processRowRepeats(row: PanelModel) {
|
||||
if (this.snapshot || this.templating.list.length === 0) {
|
||||
if (this.isSnapshotTruthy() || !this.hasVariables()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -475,7 +533,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
repeatPanel(panel: PanelModel, panelIndex: number) {
|
||||
const variable: any = _.find(this.templating.list, { name: panel.repeat } as any);
|
||||
const variable: any = this.getPanelRepeatVariable(panel);
|
||||
if (!variable) {
|
||||
return;
|
||||
}
|
||||
@@ -876,32 +934,23 @@ export class DashboardModel {
|
||||
return !_.isEqual(this.time, this.originalTime);
|
||||
}
|
||||
|
||||
resetOriginalVariables() {
|
||||
this.originalTemplating = _.map(this.templating.list, (variable: any) => {
|
||||
return {
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
current: _.cloneDeep(variable.current),
|
||||
filters: _.cloneDeep(variable.filters),
|
||||
};
|
||||
});
|
||||
resetOriginalVariables(initial = false) {
|
||||
if (!getConfig().featureToggles.newVariables) {
|
||||
this.originalTemplating = this.cloneVariablesFrom(this.templating.list);
|
||||
}
|
||||
|
||||
if (!initial && getConfig().featureToggles.newVariables) {
|
||||
// since we never change the this.variables.list when running with variables
|
||||
// in redux we can use it instead of the originalTemplating.
|
||||
this.variables.list = this.cloneVariablesFrom(getVariables());
|
||||
}
|
||||
}
|
||||
|
||||
hasVariableValuesChanged() {
|
||||
if (this.templating.list.length !== this.originalTemplating.length) {
|
||||
return false;
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
return this.hasVariablesChanged(this.variables.list, getVariables());
|
||||
}
|
||||
|
||||
const updated = _.map(this.templating.list, (variable: any) => {
|
||||
return {
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
current: _.cloneDeep(variable.current),
|
||||
filters: _.cloneDeep(variable.filters),
|
||||
};
|
||||
});
|
||||
|
||||
return !_.isEqual(updated, this.originalTemplating);
|
||||
return this.hasVariablesChanged(this.originalTemplating, this.templating.list);
|
||||
}
|
||||
|
||||
autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
|
||||
@@ -969,4 +1018,58 @@ export class DashboardModel {
|
||||
panel.render();
|
||||
}
|
||||
}
|
||||
|
||||
getVariables = () => {
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
return this.variables.list;
|
||||
}
|
||||
return this.templating.list;
|
||||
};
|
||||
|
||||
private getPanelRepeatVariable(panel: PanelModel) {
|
||||
if (!getConfig().featureToggles.newVariables) {
|
||||
return _.find(this.templating.list, { name: panel.repeat } as any);
|
||||
}
|
||||
|
||||
return getVariables().find(variable => variable.name === panel.repeat);
|
||||
}
|
||||
|
||||
private isSnapshotTruthy() {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
private hasVariables() {
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
return getVariables().length > 0;
|
||||
}
|
||||
return this.templating.list.length > 0;
|
||||
}
|
||||
|
||||
private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean {
|
||||
if (originalVariables.length !== currentVariables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated = _.map(currentVariables, (variable: any) => {
|
||||
return {
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
current: _.cloneDeep(variable.current),
|
||||
filters: _.cloneDeep(variable.filters),
|
||||
};
|
||||
});
|
||||
|
||||
return !_.isEqual(updated, originalVariables);
|
||||
}
|
||||
|
||||
private cloneVariablesFrom(variables: any[]): any[] {
|
||||
return variables.map(variable => {
|
||||
return {
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
current: _.cloneDeep(variable.current),
|
||||
filters: _.cloneDeep(variable.filters),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Emitter } from 'app/core/utils/emitter';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
// Types
|
||||
import {
|
||||
DataLink,
|
||||
DataQuery,
|
||||
DataQueryResponseData,
|
||||
PanelPlugin,
|
||||
PanelEvents,
|
||||
DataLink,
|
||||
DataTransformerConfig,
|
||||
eventFactory,
|
||||
PanelEvents,
|
||||
PanelPlugin,
|
||||
ScopedVars,
|
||||
} from '@grafana/data';
|
||||
import { EDIT_PANEL_ID } from 'app/core/constants';
|
||||
@@ -18,7 +19,6 @@ import { EDIT_PANEL_ID } from 'app/core/constants';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import { PanelQueryRunner } from './PanelQueryRunner';
|
||||
import { eventFactory } from '@grafana/data';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
export const panelAdded = eventFactory<PanelModel | undefined>('panel-added');
|
||||
@@ -133,7 +133,7 @@ export class PanelModel {
|
||||
events: Emitter;
|
||||
cacheTimeout?: any;
|
||||
cachedPluginOptions?: any;
|
||||
legend?: { show: boolean };
|
||||
legend?: { show: boolean; sort?: string; sortDesc?: boolean };
|
||||
plugin?: PanelPlugin;
|
||||
|
||||
private queryRunner?: PanelQueryRunner;
|
||||
|
||||
@@ -7,24 +7,24 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
|
||||
import { VariableSrv } from 'app/features/templating/variable_srv';
|
||||
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
|
||||
|
||||
// Actions
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { notifyApp, updateLocation } from 'app/core/actions';
|
||||
import locationUtil from 'app/core/utils/location_util';
|
||||
import {
|
||||
dashboardInitFetching,
|
||||
clearDashboardQueriesToUpdateOnLoad,
|
||||
dashboardInitCompleted,
|
||||
dashboardInitFailed,
|
||||
dashboardInitSlow,
|
||||
dashboardInitFetching,
|
||||
dashboardInitServices,
|
||||
clearDashboardQueriesToUpdateOnLoad,
|
||||
dashboardInitSlow,
|
||||
} from './reducers';
|
||||
|
||||
// Types
|
||||
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
|
||||
import { DashboardDTO, DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
import { DataQuery } from '@grafana/data';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { initDashboardTemplating, processVariables } from '../../templating/state/actions';
|
||||
import { variableAdapters } from '../../templating/adapters';
|
||||
|
||||
export interface InitDashboardArgs {
|
||||
$injector: any;
|
||||
@@ -181,7 +181,17 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
// template values service needs to initialize completely before
|
||||
// the rest of the dashboard can load
|
||||
try {
|
||||
await variableSrv.init(dashboard);
|
||||
if (!getConfig().featureToggles.newVariables) {
|
||||
await variableSrv.init(dashboard);
|
||||
}
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
const list =
|
||||
dashboard.variables.list.length > 0
|
||||
? dashboard.variables.list
|
||||
: dashboard.templating.list.filter(v => variableAdapters.contains(v.type));
|
||||
await dispatch(initDashboardTemplating(list));
|
||||
await dispatch(processVariables());
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
|
||||
console.log(err);
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { LinkSrv } from '../link_srv';
|
||||
import { DataLinkBuiltInVars } from '@grafana/ui';
|
||||
import _ from 'lodash';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { advanceTo } from 'jest-date-mock';
|
||||
import { updateConfig } from '../../../../core/config';
|
||||
|
||||
jest.mock('angular', () => {
|
||||
const AngularJSMock = require('test/mocks/angular');
|
||||
return new AngularJSMock();
|
||||
});
|
||||
jest.mock('app/core/core', () => ({
|
||||
appEvents: {
|
||||
on: () => {},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { coreModule } from 'app/core/core';
|
||||
import { VariableSrv } from 'app/features/templating/variable_srv';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { getVariables } from '../templating/state/selectors';
|
||||
|
||||
const template = `
|
||||
<div class="gf-form-select-wrapper max-width-18">
|
||||
<select class="gf-form-input" ng-model="panel.repeat" ng-options="f.value as f.text for f in variables" ng-change="optionChanged()">
|
||||
@@ -18,9 +21,17 @@ function dashRepeatOptionDirective(variableSrv: VariableSrv) {
|
||||
link: (scope: any, element: JQuery) => {
|
||||
element.css({ display: 'block', width: '100%' });
|
||||
|
||||
scope.variables = variableSrv.variables.map((item: any) => {
|
||||
return { text: item.name, value: item.name };
|
||||
});
|
||||
if (getConfig().featureToggles.newVariables) {
|
||||
scope.variables = getVariables().map((item: any) => {
|
||||
return { text: item.name, value: item.name };
|
||||
});
|
||||
}
|
||||
|
||||
if (!getConfig().featureToggles.newVariables) {
|
||||
scope.variables = variableSrv.variables.map((item: any) => {
|
||||
return { text: item.name, value: item.name };
|
||||
});
|
||||
}
|
||||
|
||||
if (scope.variables.length === 0) {
|
||||
scope.variables.unshift({
|
||||
|
||||
61
public/app/features/templating/adapters.ts
Normal file
61
public/app/features/templating/adapters.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { Reducer } from 'redux';
|
||||
import { UrlQueryValue } from '@grafana/runtime';
|
||||
|
||||
import { VariableModel, VariableOption, VariableType } from './variable';
|
||||
import { VariableEditorProps } from './editor/types';
|
||||
import { VariablesState } from './state/variablesReducer';
|
||||
import { VariablePickerProps } from './pickers/types';
|
||||
|
||||
export interface VariableAdapter<Model extends VariableModel> {
|
||||
description: string;
|
||||
label: string;
|
||||
initialState: Model;
|
||||
dependsOn: (variable: Model, variableToTest: Model) => boolean;
|
||||
setValue: (variable: Model, option: VariableOption, emitChanges?: boolean) => Promise<void>;
|
||||
setValueFromUrl: (variable: Model, urlValue: UrlQueryValue) => Promise<void>;
|
||||
updateOptions: (variable: Model, searchFilter?: string) => Promise<void>;
|
||||
getSaveModel: (variable: Model) => Partial<Model>;
|
||||
getValueForUrl: (variable: Model) => string | string[];
|
||||
picker: ComponentType<VariablePickerProps>;
|
||||
editor: ComponentType<VariableEditorProps>;
|
||||
reducer: Reducer<VariablesState>;
|
||||
}
|
||||
|
||||
const allVariableAdapters: Record<VariableType, VariableAdapter<any> | null> = {
|
||||
query: null,
|
||||
textbox: null,
|
||||
constant: null,
|
||||
datasource: null,
|
||||
custom: null,
|
||||
interval: null,
|
||||
adhoc: null,
|
||||
};
|
||||
|
||||
export interface VariableAdapters {
|
||||
contains: (type: VariableType) => boolean;
|
||||
get: (type: VariableType) => VariableAdapter<any>;
|
||||
set: (type: VariableType, adapter: VariableAdapter<any>) => void;
|
||||
registeredTypes: () => Array<{ type: VariableType; label: string }>;
|
||||
}
|
||||
|
||||
export const variableAdapters: VariableAdapters = {
|
||||
contains: (type: VariableType): boolean => !!allVariableAdapters[type],
|
||||
get: (type: VariableType): VariableAdapter<any> => {
|
||||
if (allVariableAdapters[type] !== null) {
|
||||
// @ts-ignore
|
||||
// Suppressing strict null check in this case we know that this is an instance otherwise we throw
|
||||
// Type 'VariableAdapter<any, any> | null' is not assignable to type 'VariableAdapter<any, any>'.
|
||||
// Type 'null' is not assignable to type 'VariableAdapter<any, any>'.
|
||||
return allVariableAdapters[type];
|
||||
}
|
||||
|
||||
throw new Error(`There is no adapter for type:${type}`);
|
||||
},
|
||||
set: (type, adapter) => (allVariableAdapters[type] = adapter),
|
||||
registeredTypes: (): Array<{ type: VariableType; label: string }> => {
|
||||
return Object.keys(allVariableAdapters)
|
||||
.filter((key: VariableType) => allVariableAdapters[key] !== null)
|
||||
.map((key: VariableType) => ({ type: key, label: allVariableAdapters[key]!.label }));
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,11 @@ import { CustomVariable } from './custom_variable';
|
||||
import { ConstantVariable } from './constant_variable';
|
||||
import { AdhocVariable } from './adhoc_variable';
|
||||
import { TextBoxVariable } from './TextBoxVariable';
|
||||
import { variableAdapters } from './adapters';
|
||||
import { createQueryVariableAdapter } from './query/adapter';
|
||||
import { createCustomVariableAdapter } from './custom/adapter';
|
||||
import { createTextBoxVariableAdapter } from './textbox/adapter';
|
||||
import { createConstantVariableAdapter } from './constant/adapter';
|
||||
|
||||
coreModule.factory('templateSrv', () => templateSrv);
|
||||
|
||||
@@ -23,3 +28,8 @@ export {
|
||||
AdhocVariable,
|
||||
TextBoxVariable,
|
||||
};
|
||||
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { ChangeEvent, FocusEvent, PureComponent } from 'react';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
import { ConstantVariableModel } from '../variable';
|
||||
import { VariableEditorProps } from '../editor/types';
|
||||
|
||||
export interface Props extends VariableEditorProps<ConstantVariableModel> {}
|
||||
|
||||
export class ConstantVariableEditor extends PureComponent<Props> {
|
||||
onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({
|
||||
propName: 'query',
|
||||
propValue: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({
|
||||
propName: 'query',
|
||||
propValue: event.target.value,
|
||||
updateOptions: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-group">
|
||||
<h5 className="section-heading">Constant options</h5>
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Value</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.query}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder="your metric prefix"
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.selectors.constantOptionsQueryInput
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
57
public/app/features/templating/constant/actions.test.ts
Normal file
57
public/app/features/templating/constant/actions.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createConstantVariableAdapter } from '../constant/adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { TemplatingState } from 'app/features/templating/state/reducers';
|
||||
import { updateConstantVariableOptions } from './actions';
|
||||
import { getTemplatingRootReducer } from '../state/helpers';
|
||||
import { ConstantVariableModel, VariableOption, VariableHide } from '../variable';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { createConstantOptionsFromQuery } from './reducer';
|
||||
import { setCurrentVariableValue } from '../state/sharedReducer';
|
||||
import { initDashboardTemplating } from '../state/actions';
|
||||
|
||||
describe('constant actions', () => {
|
||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||
|
||||
describe('when updateConstantVariableOptions is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const option: VariableOption = {
|
||||
value: 'A',
|
||||
text: 'A',
|
||||
selected: false,
|
||||
};
|
||||
|
||||
const variable: ConstantVariableModel = {
|
||||
type: 'constant',
|
||||
uuid: '0',
|
||||
global: false,
|
||||
current: {
|
||||
value: '',
|
||||
text: '',
|
||||
selected: false,
|
||||
},
|
||||
options: [],
|
||||
query: 'A',
|
||||
name: 'Constant',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateConstantVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [createAction, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(createAction).toEqual(createConstantOptionsFromQuery(toVariablePayload(variable)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
11
public/app/features/templating/constant/actions.ts
Normal file
11
public/app/features/templating/constant/actions.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { validateVariableSelectionState } from '../state/actions';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { createConstantOptionsFromQuery } from './reducer';
|
||||
import { toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
|
||||
export const updateConstantVariableOptions = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
await dispatch(createConstantOptionsFromQuery(toVariablePayload(identifier)));
|
||||
await dispatch(validateVariableSelectionState(identifier));
|
||||
};
|
||||
};
|
||||
40
public/app/features/templating/constant/adapter.ts
Normal file
40
public/app/features/templating/constant/adapter.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { ConstantVariableModel } from '../variable';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions';
|
||||
import { VariableAdapter } from '../adapters';
|
||||
import { constantVariableReducer, initialConstantVariableModelState } from './reducer';
|
||||
import { OptionsPicker } from '../pickers';
|
||||
import { ConstantVariableEditor } from './ConstantVariableEditor';
|
||||
import { updateConstantVariableOptions } from './actions';
|
||||
import { toVariableIdentifier } from '../state/types';
|
||||
|
||||
export const createConstantVariableAdapter = (): VariableAdapter<ConstantVariableModel> => {
|
||||
return {
|
||||
description: 'Define a hidden constant variable, useful for metric prefixes in dashboards you want to share',
|
||||
label: 'Constant',
|
||||
initialState: initialConstantVariableModelState,
|
||||
reducer: constantVariableReducer,
|
||||
picker: OptionsPicker,
|
||||
editor: ConstantVariableEditor,
|
||||
dependsOn: () => {
|
||||
return false;
|
||||
},
|
||||
setValue: async (variable, option, emitChanges = false) => {
|
||||
await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges));
|
||||
},
|
||||
setValueFromUrl: async (variable, urlValue) => {
|
||||
await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue));
|
||||
},
|
||||
updateOptions: async variable => {
|
||||
await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable)));
|
||||
},
|
||||
getSaveModel: variable => {
|
||||
const { index, uuid, initLock, global, ...rest } = cloneDeep(variable);
|
||||
return rest;
|
||||
},
|
||||
getValueForUrl: variable => {
|
||||
return variable.current.value;
|
||||
},
|
||||
};
|
||||
};
|
||||
62
public/app/features/templating/constant/reducer.test.ts
Normal file
62
public/app/features/templating/constant/reducer.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { getVariableTestContext } from '../state/helpers';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { constantVariableReducer, createConstantOptionsFromQuery } from './reducer';
|
||||
import { VariablesState } from '../state/variablesReducer';
|
||||
import { ConstantVariableModel } from '../variable';
|
||||
import { createConstantVariableAdapter } from './adapter';
|
||||
|
||||
describe('constantVariableReducer', () => {
|
||||
const adapter = createConstantVariableAdapter();
|
||||
|
||||
describe('when createConstantOptionsFromQuery is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = 'ABC';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'constant' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(constantVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createConstantOptionsFromQuery(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: query,
|
||||
value: query,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
} as ConstantVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when createConstantOptionsFromQuery is dispatched and query contains spaces', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = ' ABC ';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'constant' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(constantVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createConstantOptionsFromQuery(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: query.trim(),
|
||||
value: query.trim(),
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
} as ConstantVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
36
public/app/features/templating/constant/reducer.ts
Normal file
36
public/app/features/templating/constant/reducer.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ConstantVariableModel, VariableHide, VariableOption } from '../variable';
|
||||
import { EMPTY_UUID, getInstanceState, VariablePayload } from '../state/types';
|
||||
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
|
||||
|
||||
export const initialConstantVariableModelState: ConstantVariableModel = {
|
||||
uuid: EMPTY_UUID,
|
||||
global: false,
|
||||
type: 'constant',
|
||||
name: '',
|
||||
hide: VariableHide.hideVariable,
|
||||
label: '',
|
||||
query: '',
|
||||
current: {} as VariableOption,
|
||||
options: [],
|
||||
skipUrlSync: false,
|
||||
index: -1,
|
||||
initLock: null,
|
||||
};
|
||||
|
||||
export const constantVariableSlice = createSlice({
|
||||
name: 'templating/constant',
|
||||
initialState: initialVariablesState,
|
||||
reducers: {
|
||||
createConstantOptionsFromQuery: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState<ConstantVariableModel>(state, action.payload.uuid);
|
||||
instanceState.options = [
|
||||
{ text: instanceState.query.trim(), value: instanceState.query.trim(), selected: false },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const constantVariableReducer = constantVariableSlice.reducer;
|
||||
|
||||
export const { createConstantOptionsFromQuery } = constantVariableSlice.actions;
|
||||
@@ -22,7 +22,7 @@ export class ConstantVariable implements ConstantVariableModel, VariableActions
|
||||
defaults: ConstantVariableModel = {
|
||||
type: 'constant',
|
||||
name: '',
|
||||
hide: VariableHide.hideLabel,
|
||||
hide: VariableHide.hideVariable,
|
||||
label: '',
|
||||
query: '',
|
||||
current: {} as VariableOption,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { ChangeEvent, FocusEvent, PureComponent } from 'react';
|
||||
import { CustomVariableModel, VariableWithMultiSupport } from '../variable';
|
||||
import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor';
|
||||
import { OnPropChangeArguments, VariableEditorProps } from '../editor/types';
|
||||
|
||||
export interface Props extends VariableEditorProps<CustomVariableModel> {}
|
||||
|
||||
export class CustomVariableEditor extends PureComponent<Props> {
|
||||
onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({
|
||||
propName: 'query',
|
||||
propValue: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onSelectionOptionsChange = async ({ propName, propValue }: OnPropChangeArguments<VariableWithMultiSupport>) => {
|
||||
this.props.onPropChange({ propName, propValue, updateOptions: true });
|
||||
};
|
||||
|
||||
onBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({
|
||||
propName: 'query',
|
||||
propValue: event.target.value,
|
||||
updateOptions: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-group">
|
||||
<h5 className="section-heading">Custom Options</h5>
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label width-14">Values separated by comma</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.query}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder="1, 10, 20, myvalue, escaped\,value"
|
||||
required
|
||||
aria-label="Variable editor Form Custom Query field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectionOptionsEditor variable={this.props.variable} onPropChange={this.onSelectionOptionsChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
70
public/app/features/templating/custom/actions.test.ts
Normal file
70
public/app/features/templating/custom/actions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { updateCustomVariableOptions } from './actions';
|
||||
import { createCustomVariableAdapter } from './adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { getTemplatingRootReducer } from '../state/helpers';
|
||||
import { VariableOption, VariableHide, CustomVariableModel } from '../variable';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { setCurrentVariableValue } from '../state/sharedReducer';
|
||||
import { initDashboardTemplating } from '../state/actions';
|
||||
import { TemplatingState } from '../state/reducers';
|
||||
import { createCustomOptionsFromQuery } from './reducer';
|
||||
|
||||
describe('custom actions', () => {
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
|
||||
describe('when updateCustomVariableOptions is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const option: VariableOption = {
|
||||
value: 'A',
|
||||
text: 'A',
|
||||
selected: false,
|
||||
};
|
||||
|
||||
const variable: CustomVariableModel = {
|
||||
type: 'custom',
|
||||
uuid: '0',
|
||||
global: false,
|
||||
current: {
|
||||
value: '',
|
||||
text: '',
|
||||
selected: false,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
text: 'A',
|
||||
value: 'A',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'B',
|
||||
value: 'B',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
query: 'A,B',
|
||||
name: 'Custom',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
index: 0,
|
||||
multi: true,
|
||||
includeAll: false,
|
||||
};
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateCustomVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [createAction, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(createAction).toEqual(createCustomOptionsFromQuery(toVariablePayload(variable)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
11
public/app/features/templating/custom/actions.ts
Normal file
11
public/app/features/templating/custom/actions.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { validateVariableSelectionState } from '../state/actions';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { createCustomOptionsFromQuery } from './reducer';
|
||||
import { toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
|
||||
export const updateCustomVariableOptions = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
await dispatch(createCustomOptionsFromQuery(toVariablePayload(identifier)));
|
||||
await dispatch(validateVariableSelectionState(identifier));
|
||||
};
|
||||
};
|
||||
43
public/app/features/templating/custom/adapter.ts
Normal file
43
public/app/features/templating/custom/adapter.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { CustomVariableModel } from '../variable';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions';
|
||||
import { VariableAdapter } from '../adapters';
|
||||
import { customVariableReducer, initialCustomVariableModelState } from './reducer';
|
||||
import { OptionsPicker } from '../pickers';
|
||||
import { CustomVariableEditor } from './CustomVariableEditor';
|
||||
import { updateCustomVariableOptions } from './actions';
|
||||
import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types';
|
||||
|
||||
export const createCustomVariableAdapter = (): VariableAdapter<CustomVariableModel> => {
|
||||
return {
|
||||
description: 'Define variable values manually',
|
||||
label: 'Custom',
|
||||
initialState: initialCustomVariableModelState,
|
||||
reducer: customVariableReducer,
|
||||
picker: OptionsPicker,
|
||||
editor: CustomVariableEditor,
|
||||
dependsOn: () => {
|
||||
return false;
|
||||
},
|
||||
setValue: async (variable, option, emitChanges = false) => {
|
||||
await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges));
|
||||
},
|
||||
setValueFromUrl: async (variable, urlValue) => {
|
||||
await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue));
|
||||
},
|
||||
updateOptions: async variable => {
|
||||
await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable)));
|
||||
},
|
||||
getSaveModel: variable => {
|
||||
const { index, uuid, initLock, global, ...rest } = cloneDeep(variable);
|
||||
return rest;
|
||||
},
|
||||
getValueForUrl: variable => {
|
||||
if (variable.current.text === ALL_VARIABLE_TEXT) {
|
||||
return ALL_VARIABLE_TEXT;
|
||||
}
|
||||
return variable.current.value;
|
||||
},
|
||||
};
|
||||
};
|
||||
122
public/app/features/templating/custom/reducer.test.ts
Normal file
122
public/app/features/templating/custom/reducer.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { getVariableTestContext } from '../state/helpers';
|
||||
import { toVariablePayload, ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../state/types';
|
||||
import { customVariableReducer, createCustomOptionsFromQuery } from './reducer';
|
||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||
import { VariablesState } from '../state/variablesReducer';
|
||||
import { CustomVariableModel } from '../variable';
|
||||
|
||||
describe('customVariableReducer', () => {
|
||||
const adapter = createCustomVariableAdapter();
|
||||
|
||||
describe('when createCustomOptionsFromQuery is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = 'a,b,c';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'custom' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(customVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createCustomOptionsFromQuery(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: 'a',
|
||||
value: 'a',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
value: 'b',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
value: 'c',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
} as CustomVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when createCustomOptionsFromQuery is dispatched and query contains spaces', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = 'a, b, c';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'constant' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(customVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createCustomOptionsFromQuery(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: 'a',
|
||||
value: 'a',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
value: 'b',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
value: 'c',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
} as CustomVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when createCustomOptionsFromQuery is dispatched and includeAll is true', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = 'a,b,c';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query, includeAll: true });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'constant' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(customVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createCustomOptionsFromQuery(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: ALL_VARIABLE_TEXT,
|
||||
value: ALL_VARIABLE_VALUE,
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'a',
|
||||
value: 'a',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
value: 'b',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
value: 'c',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
} as CustomVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
public/app/features/templating/custom/reducer.ts
Normal file
50
public/app/features/templating/custom/reducer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { CustomVariableModel, VariableHide, VariableOption } from '../variable';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, EMPTY_UUID, getInstanceState, VariablePayload } from '../state/types';
|
||||
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
|
||||
|
||||
export const initialCustomVariableModelState: CustomVariableModel = {
|
||||
uuid: EMPTY_UUID,
|
||||
global: false,
|
||||
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({
|
||||
name: 'templating/custom',
|
||||
initialState: initialVariablesState,
|
||||
reducers: {
|
||||
createCustomOptionsFromQuery: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState<CustomVariableModel>(state, action.payload.uuid);
|
||||
const { includeAll, query } = instanceState;
|
||||
const match = query.match(/(?:\\,|[^,])+/g) ?? [];
|
||||
|
||||
const options = match.map(text => {
|
||||
text = text.replace(/\\,/g, ',');
|
||||
return { text: text.trim(), value: text.trim(), selected: false };
|
||||
});
|
||||
|
||||
if (includeAll) {
|
||||
options.unshift({ text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false });
|
||||
}
|
||||
|
||||
instanceState.options = options;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const customVariableReducer = customVariableSlice.reducer;
|
||||
|
||||
export const { createCustomOptionsFromQuery } = customVariableSlice.actions;
|
||||
@@ -1,26 +1,40 @@
|
||||
import { assignModelProperties, containsVariable, VariableActions, variableTypes } from './variable';
|
||||
import {
|
||||
assignModelProperties,
|
||||
containsVariable,
|
||||
DataSourceVariableModel,
|
||||
VariableActions,
|
||||
VariableHide,
|
||||
VariableOption,
|
||||
VariableRefresh,
|
||||
VariableType,
|
||||
variableTypes,
|
||||
} from './variable';
|
||||
import { stringToJsRegex } from '@grafana/data';
|
||||
import { VariableSrv } from './variable_srv';
|
||||
import { TemplateSrv } from './template_srv';
|
||||
import { DatasourceSrv } from '../plugins/datasource_srv';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export class DatasourceVariable implements VariableActions {
|
||||
export class DatasourceVariable implements DataSourceVariableModel, VariableActions {
|
||||
type: VariableType;
|
||||
name: string;
|
||||
label: string;
|
||||
hide: VariableHide;
|
||||
regex: any;
|
||||
query: string;
|
||||
options: any;
|
||||
current: any;
|
||||
options: VariableOption[];
|
||||
current: VariableOption;
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
refresh: any;
|
||||
refresh: VariableRefresh;
|
||||
skipUrlSync: boolean;
|
||||
|
||||
defaults: any = {
|
||||
defaults: DataSourceVariableModel = {
|
||||
type: 'datasource',
|
||||
name: '',
|
||||
hide: 0,
|
||||
label: '',
|
||||
current: {},
|
||||
current: {} as VariableOption,
|
||||
regex: '',
|
||||
options: [],
|
||||
query: '',
|
||||
@@ -54,12 +68,12 @@ export class DatasourceVariable implements VariableActions {
|
||||
}
|
||||
|
||||
updateOptions() {
|
||||
const options = [];
|
||||
const options: VariableOption[] = [];
|
||||
const sources = this.datasourceSrv.getMetricSources({ skipVariables: true });
|
||||
let regex;
|
||||
|
||||
if (this.regex) {
|
||||
regex = this.templateSrv.replace(this.regex, null, 'regex');
|
||||
regex = this.templateSrv.replace(this.regex, undefined, 'regex');
|
||||
regex = stringToJsRegex(regex);
|
||||
}
|
||||
|
||||
@@ -74,11 +88,11 @@ export class DatasourceVariable implements VariableActions {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push({ text: source.name, value: source.name });
|
||||
options.push({ text: source.name, value: source.name, selected: false });
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
options.push({ text: 'No data sources found', value: '' });
|
||||
options.push({ text: 'No data sources found', value: '', selected: false });
|
||||
}
|
||||
|
||||
this.options = options;
|
||||
@@ -90,7 +104,7 @@ export class DatasourceVariable implements VariableActions {
|
||||
}
|
||||
|
||||
addAllOption() {
|
||||
this.options.unshift({ text: 'All', value: '$__all' });
|
||||
this.options.unshift({ text: 'All', value: '$__all', selected: false });
|
||||
}
|
||||
|
||||
dependsOn(variable: any) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import { Switch } from '@grafana/ui';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
import { VariableWithMultiSupport } from '../variable';
|
||||
import { VariableEditorProps } from './types';
|
||||
|
||||
export interface SelectionOptionsEditorProps<Model extends VariableWithMultiSupport = VariableWithMultiSupport>
|
||||
extends VariableEditorProps<Model> {}
|
||||
|
||||
export const SelectionOptionsEditor: FunctionComponent<SelectionOptionsEditorProps> = props => {
|
||||
const onMultiChanged = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onPropChange({ propName: 'multi', propValue: event.target.checked });
|
||||
},
|
||||
[props.onPropChange]
|
||||
);
|
||||
|
||||
const onIncludeAllChanged = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onPropChange({ propName: 'includeAll', propValue: event.target.checked });
|
||||
},
|
||||
[props.onPropChange]
|
||||
);
|
||||
|
||||
const onAllValueChanged = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onPropChange({ propName: 'allValue', propValue: event.target.value });
|
||||
},
|
||||
[props.onPropChange]
|
||||
);
|
||||
return (
|
||||
<div className="section gf-form-group">
|
||||
<h5 className="section-heading">Selection Options</h5>
|
||||
<div className="section">
|
||||
<div aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.selectionOptionsMultiSwitch}>
|
||||
<Switch
|
||||
label="Multi-value"
|
||||
labelClass="width-10"
|
||||
checked={props.variable.multi}
|
||||
onChange={onMultiChanged}
|
||||
tooltip={'Enables multiple values to be selected at the same time'}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.selectionOptionsIncludeAllSwitch}
|
||||
>
|
||||
<Switch
|
||||
label="Include All option"
|
||||
labelClass="width-10"
|
||||
checked={props.variable.includeAll}
|
||||
onChange={onIncludeAllChanged}
|
||||
tooltip={'Enables multiple values to be selected at the same time'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.variable.includeAll && (
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label width-10">Custom all value</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input max-width-15"
|
||||
value={props.variable.allValue ?? ''}
|
||||
onChange={onAllValueChanged}
|
||||
placeholder="blank = auto"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.selectionOptionsCustomAllInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
SelectionOptionsEditor.displayName = 'SelectionOptionsEditor';
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { MouseEvent, PureComponent } from 'react';
|
||||
import { EMPTY_UUID, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
import { StoreState } from '../../../types';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { VariableEditorList } from './VariableEditorList';
|
||||
import { VariableEditorEditor } from './VariableEditorEditor';
|
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
|
||||
import { getVariableClones } from '../state/selectors';
|
||||
import { VariableModel } from '../variable';
|
||||
import { switchToEditMode, switchToListMode, switchToNewMode } from './actions';
|
||||
import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer';
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
interface ConnectedProps {
|
||||
idInEditor: string | null;
|
||||
variables: VariableModel[];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
changeVariableOrder: typeof changeVariableOrder;
|
||||
duplicateVariable: typeof duplicateVariable;
|
||||
removeVariable: typeof removeVariable;
|
||||
switchToNewMode: typeof switchToNewMode;
|
||||
switchToEditMode: typeof switchToEditMode;
|
||||
switchToListMode: typeof switchToListMode;
|
||||
}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
class VariableEditorContainerUnconnected extends PureComponent<Props> {
|
||||
componentDidMount(): void {
|
||||
this.props.switchToListMode();
|
||||
}
|
||||
|
||||
onChangeToListMode = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.switchToListMode();
|
||||
};
|
||||
|
||||
onEditVariable = (identifier: VariableIdentifier) => {
|
||||
this.props.switchToEditMode(identifier);
|
||||
};
|
||||
|
||||
onNewVariable = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.switchToNewMode();
|
||||
};
|
||||
|
||||
onChangeVariableOrder = (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => {
|
||||
this.props.changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex }));
|
||||
};
|
||||
|
||||
onDuplicateVariable = (identifier: VariableIdentifier) => {
|
||||
this.props.duplicateVariable(toVariablePayload(identifier));
|
||||
};
|
||||
|
||||
onRemoveVariable = (identifier: VariableIdentifier) => {
|
||||
this.props.removeVariable(toVariablePayload(identifier, { reIndex: true }));
|
||||
};
|
||||
|
||||
render() {
|
||||
const variableToEdit = this.props.variables.find(s => s.uuid === this.props.idInEditor) ?? null;
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
<h3 className="dashboard-settings__header">
|
||||
<a
|
||||
onClick={this.onChangeToListMode}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.headerLink}
|
||||
>
|
||||
Variables
|
||||
</a>
|
||||
{this.props.idInEditor === EMPTY_UUID && (
|
||||
<span>
|
||||
<i
|
||||
className="fa fa-fw fa-chevron-right"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.modeLabelNew}
|
||||
/>
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
{this.props.idInEditor && this.props.idInEditor !== EMPTY_UUID && (
|
||||
<span>
|
||||
<i
|
||||
className="fa fa-fw fa-chevron-right"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.modeLabelEdit}
|
||||
/>
|
||||
Edit
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="page-action-bar__spacer" />
|
||||
{this.props.variables.length > 0 && variableToEdit === null && (
|
||||
<a
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={this.onNewVariable}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.newButton}
|
||||
>
|
||||
New
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!variableToEdit && (
|
||||
<VariableEditorList
|
||||
variables={this.props.variables}
|
||||
onAddClick={this.onNewVariable}
|
||||
onEditClick={this.onEditVariable}
|
||||
onChangeVariableOrder={this.onChangeVariableOrder}
|
||||
onDuplicateVariable={this.onDuplicateVariable}
|
||||
onRemoveVariable={this.onRemoveVariable}
|
||||
/>
|
||||
)}
|
||||
{variableToEdit && <VariableEditorEditor identifier={toVariableIdentifier(variableToEdit)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
|
||||
variables: getVariableClones(state, true),
|
||||
idInEditor: state.templating.editor.id,
|
||||
});
|
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||
changeVariableOrder,
|
||||
duplicateVariable,
|
||||
removeVariable,
|
||||
switchToNewMode,
|
||||
switchToEditMode,
|
||||
switchToListMode,
|
||||
};
|
||||
|
||||
export const VariableEditorContainer = connectWithStore(
|
||||
VariableEditorContainerUnconnected,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
);
|
||||
247
public/app/features/templating/editor/VariableEditorEditor.tsx
Normal file
247
public/app/features/templating/editor/VariableEditorEditor.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { ChangeEvent, FormEvent, PureComponent } from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { FormLabel } from '@grafana/ui';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { EMPTY_UUID, toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
import { VariableHide, VariableModel, VariableType } from '../variable';
|
||||
import { appEvents } from '../../../core/core';
|
||||
import { VariableValuesPreview } from './VariableValuesPreview';
|
||||
import { changeVariableName, onEditorAdd, onEditorUpdate, variableEditorMount, variableEditorUnMount } from './actions';
|
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { StoreState } from '../../../types';
|
||||
import { VariableEditorState } from './reducer';
|
||||
import { getVariable } from '../state/selectors';
|
||||
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
|
||||
import { OnPropChangeArguments } from './types';
|
||||
import { changeVariableProp, changeVariableType } from '../state/sharedReducer';
|
||||
|
||||
export interface OwnProps {
|
||||
identifier: VariableIdentifier;
|
||||
}
|
||||
|
||||
interface ConnectedProps {
|
||||
editor: VariableEditorState;
|
||||
variable: VariableModel;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
variableEditorMount: typeof variableEditorMount;
|
||||
variableEditorUnMount: typeof variableEditorUnMount;
|
||||
changeVariableName: typeof changeVariableName;
|
||||
changeVariableProp: typeof changeVariableProp;
|
||||
onEditorUpdate: typeof onEditorUpdate;
|
||||
onEditorAdd: typeof onEditorAdd;
|
||||
changeVariableType: typeof changeVariableType;
|
||||
}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export class VariableEditorEditorUnConnected extends PureComponent<Props> {
|
||||
componentDidMount(): void {
|
||||
this.props.variableEditorMount(this.props.identifier);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<{}>, snapshot?: any): void {
|
||||
if (!isEqual(prevProps.editor.errors, this.props.editor.errors)) {
|
||||
Object.values(this.props.editor.errors).forEach(error => {
|
||||
appEvents.emit(AppEvents.alertWarning, ['Validation', error]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.props.variableEditorUnMount(this.props.identifier);
|
||||
}
|
||||
|
||||
onNameChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.changeVariableName(this.props.identifier, event.target.value);
|
||||
};
|
||||
|
||||
onTypeChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.changeVariableType(
|
||||
toVariablePayload(this.props.identifier, { newType: event.target.value as VariableType })
|
||||
);
|
||||
};
|
||||
|
||||
onLabelChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.changeVariableProp(
|
||||
toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.target.value })
|
||||
);
|
||||
};
|
||||
|
||||
onHideChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.changeVariableProp(
|
||||
toVariablePayload(this.props.identifier, {
|
||||
propName: 'hide',
|
||||
propValue: parseInt(event.target.value, 10) as VariableHide,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
onHandleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!this.props.editor.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.variable.uuid !== EMPTY_UUID) {
|
||||
await this.props.onEditorUpdate(this.props.identifier);
|
||||
}
|
||||
|
||||
if (this.props.variable.uuid === EMPTY_UUID) {
|
||||
await this.props.onEditorAdd(this.props.identifier);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const EditorToRender = variableAdapters.get(this.props.variable.type).editor;
|
||||
if (!EditorToRender) {
|
||||
return null;
|
||||
}
|
||||
const newVariable = this.props.variable.uuid && this.props.variable.uuid === EMPTY_UUID;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form aria-label="Variable editor Form" onSubmit={this.onHandleSubmit}>
|
||||
<h5 className="section-heading">General</h5>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-19">
|
||||
<span className="gf-form-label width-6">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
name="name"
|
||||
placeholder="name"
|
||||
required
|
||||
value={this.props.editor.name}
|
||||
onChange={this.onNameChange}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalNameInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form max-width-19">
|
||||
<FormLabel width={6} tooltip={variableAdapters.get(this.props.variable.type).description}>
|
||||
Type
|
||||
</FormLabel>
|
||||
<div className="gf-form-select-wrapper max-width-17">
|
||||
<select
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.type}
|
||||
onChange={this.onTypeChange}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalTypeSelect}
|
||||
>
|
||||
{variableAdapters.registeredTypes().map(item => (
|
||||
<option key={item.type} label={item.label} value={item.type}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.editor.errors.name && (
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label gf-form-label--error">{this.props.editor.errors.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-19">
|
||||
<span className="gf-form-label width-6">Label</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.label ?? ''}
|
||||
onChange={this.onLabelChange}
|
||||
placeholder="optional display name"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalLabelInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form max-width-19">
|
||||
<span className="gf-form-label width-6">Hide</span>
|
||||
<div className="gf-form-select-wrapper max-width-15">
|
||||
<select
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.hide}
|
||||
onChange={this.onHideChange}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.generalHideSelect}
|
||||
>
|
||||
<option label="" value={VariableHide.dontHide}>
|
||||
{''}
|
||||
</option>
|
||||
<option label="" value={VariableHide.hideLabel}>
|
||||
Label
|
||||
</option>
|
||||
<option label="" value={VariableHide.hideVariable}>
|
||||
Variable
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{EditorToRender && <EditorToRender variable={this.props.variable} onPropChange={this.onPropChanged} />}
|
||||
|
||||
<VariableValuesPreview variable={this.props.variable} />
|
||||
|
||||
<div className="gf-form-button-row p-y-0">
|
||||
{!newVariable && (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.updateButton}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{newVariable && (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.addButton}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, ownProps) => ({
|
||||
editor: state.templating.editor,
|
||||
variable: getVariable(ownProps.identifier.uuid!, state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||
variableEditorMount,
|
||||
variableEditorUnMount,
|
||||
changeVariableName,
|
||||
changeVariableProp,
|
||||
onEditorUpdate,
|
||||
onEditorAdd,
|
||||
changeVariableType,
|
||||
};
|
||||
|
||||
export const VariableEditorEditor = connectWithStore(
|
||||
VariableEditorEditorUnConnected,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
);
|
||||
165
public/app/features/templating/editor/VariableEditorList.tsx
Normal file
165
public/app/features/templating/editor/VariableEditorList.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { MouseEvent, PureComponent } from 'react';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import EmptyListCTA from '../../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { QueryVariableModel, VariableModel } from '../variable';
|
||||
import { toVariableIdentifier, VariableIdentifier } from '../state/types';
|
||||
|
||||
export interface Props {
|
||||
variables: VariableModel[];
|
||||
onAddClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
onEditClick: (identifier: VariableIdentifier) => void;
|
||||
onChangeVariableOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void;
|
||||
onDuplicateVariable: (identifier: VariableIdentifier) => void;
|
||||
onRemoveVariable: (identifier: VariableIdentifier) => void;
|
||||
}
|
||||
|
||||
enum MoveType {
|
||||
down = 1,
|
||||
up = -1,
|
||||
}
|
||||
|
||||
export class VariableEditorList extends PureComponent<Props> {
|
||||
onEditClick = (event: MouseEvent, identifier: VariableIdentifier) => {
|
||||
event.preventDefault();
|
||||
this.props.onEditClick(identifier);
|
||||
};
|
||||
|
||||
onChangeVariableOrder = (event: MouseEvent, variable: VariableModel, moveType: MoveType) => {
|
||||
event.preventDefault();
|
||||
this.props.onChangeVariableOrder(toVariableIdentifier(variable), variable.index!, variable.index! + moveType);
|
||||
};
|
||||
|
||||
onDuplicateVariable = (event: MouseEvent, identifier: VariableIdentifier) => {
|
||||
event.preventDefault();
|
||||
this.props.onDuplicateVariable(identifier);
|
||||
};
|
||||
|
||||
onRemoveVariable = (event: MouseEvent, identifier: VariableIdentifier) => {
|
||||
event.preventDefault();
|
||||
this.props.onRemoveVariable(identifier);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{this.props.variables.length === 0 && (
|
||||
<div>
|
||||
<EmptyListCTA
|
||||
title="There are no variables yet"
|
||||
buttonIcon="gicon gicon-variable"
|
||||
buttonTitle="Add variable"
|
||||
infoBox={{
|
||||
__html: ` <p>
|
||||
Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server
|
||||
or sensor names in your metric queries you can use variables in their place. Variables are shown as
|
||||
dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the data
|
||||
being displayed in your dashboard. Check out the
|
||||
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
|
||||
Templating documentation
|
||||
</a>
|
||||
for more information.
|
||||
</p>`,
|
||||
}}
|
||||
infoBoxTitle="What do variables do?"
|
||||
onClick={this.props.onAddClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.variables.length > 0 && (
|
||||
<div>
|
||||
<table
|
||||
className="filter-table filter-table--hover"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.table}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Definition</th>
|
||||
<th colSpan={5} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.props.variables.map((state, index) => {
|
||||
const variable = state as QueryVariableModel;
|
||||
return (
|
||||
<tr key={`${variable.name}-${index}`}>
|
||||
<td style={{ width: '1%' }}>
|
||||
<span
|
||||
onClick={event => this.onEditClick(event, toVariableIdentifier(variable))}
|
||||
className="pointer template-variable"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowNameFields(
|
||||
variable.name
|
||||
)}
|
||||
>
|
||||
{variable.name}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{ maxWidth: '200px' }}
|
||||
onClick={event => this.onEditClick(event, toVariableIdentifier(variable))}
|
||||
className="pointer max-width"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowDefinitionFields(
|
||||
variable.name
|
||||
)}
|
||||
>
|
||||
{variable.definition ? variable.definition : variable.query}
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
{index > 0 && (
|
||||
<i
|
||||
onClick={event => this.onChangeVariableOrder(event, variable, MoveType.up)}
|
||||
className="pointer fa fa-arrow-up"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowArrowUpButtons(
|
||||
variable.name
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
{index < this.props.variables.length - 1 && (
|
||||
<i
|
||||
onClick={event => this.onChangeVariableOrder(event, variable, MoveType.down)}
|
||||
className="pointer fa fa-arrow-down"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowArrowDownButtons(
|
||||
variable.name
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<a
|
||||
onClick={event => this.onDuplicateVariable(event, toVariableIdentifier(variable))}
|
||||
className="btn btn-inverse btn-small"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowDuplicateButtons(
|
||||
variable.name
|
||||
)}
|
||||
>
|
||||
Duplicate
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<a
|
||||
onClick={event => this.onRemoveVariable(event, toVariableIdentifier(variable))}
|
||||
className="btn btn-danger btn-small"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowRemoveButtons(
|
||||
variable.name
|
||||
)}
|
||||
>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { VariableModel, VariableOption, VariableWithOptions } from '../variable';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
|
||||
export interface VariableValuesPreviewProps {
|
||||
variable: VariableModel;
|
||||
}
|
||||
|
||||
export const VariableValuesPreview: React.FunctionComponent<VariableValuesPreviewProps> = ({ variable }) => {
|
||||
const [previewLimit, setPreviewLimit] = useState(20);
|
||||
const [previewOptions, setPreviewOptions] = useState<VariableOption[]>([]);
|
||||
const showMoreOptions = useCallback(() => setPreviewLimit(previewLimit + 20), [previewLimit, setPreviewLimit]);
|
||||
useEffect(() => {
|
||||
if (!variable || !variable.hasOwnProperty('options')) {
|
||||
return;
|
||||
}
|
||||
const variableWithOptions = variable as VariableWithOptions;
|
||||
setPreviewOptions(variableWithOptions.options.slice(0, previewLimit));
|
||||
}, [previewLimit, variable]);
|
||||
|
||||
if (!previewOptions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<h5>Preview of values</h5>
|
||||
<div className="gf-form-inline">
|
||||
{previewOptions.map((o, index) => (
|
||||
<div className="gf-form" key={`${o.value}-${index}`}>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.General.selectors.previewOfValuesOption}
|
||||
>
|
||||
{o.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{previewOptions.length > previewLimit && (
|
||||
<div className="gf-form" ng-if="current.options.length > optionsLimit">
|
||||
<a
|
||||
className="gf-form-label btn-secondary"
|
||||
onClick={showMoreOptions}
|
||||
aria-label="Variable editor Preview of Values Show More link"
|
||||
>
|
||||
Show more
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
101
public/app/features/templating/editor/actions.ts
Normal file
101
public/app/features/templating/editor/actions.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ThunkResult } from '../../../types';
|
||||
import { getVariable, getVariables } from '../state/selectors';
|
||||
import {
|
||||
changeVariableNameFailed,
|
||||
changeVariableNameSucceeded,
|
||||
clearIdInEditor,
|
||||
setIdInEditor,
|
||||
variableEditorMounted,
|
||||
variableEditorUnMounted,
|
||||
} from './reducer';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { v4 } from 'uuid';
|
||||
import { AddVariable, EMPTY_UUID, toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { VariableType } from '../variable';
|
||||
import { addVariable, removeVariable, storeNewVariable } from '../state/sharedReducer';
|
||||
|
||||
export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
dispatch(variableEditorMounted({ name: getVariable(identifier.uuid!).name }));
|
||||
};
|
||||
};
|
||||
|
||||
export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(variableEditorUnMounted(toVariablePayload(identifier)));
|
||||
if (getState().templating.variables[EMPTY_UUID]) {
|
||||
dispatch(removeVariable(toVariablePayload({ type: identifier.type, uuid: EMPTY_UUID }, { reIndex: false })));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variableInState = getVariable(identifier.uuid!, getState());
|
||||
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
|
||||
dispatch(switchToListMode());
|
||||
};
|
||||
};
|
||||
|
||||
export const onEditorAdd = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const uuid = v4();
|
||||
dispatch(storeNewVariable(toVariablePayload({ type: identifier.type, uuid })));
|
||||
const variableInState = getVariable(uuid, getState());
|
||||
await variableAdapters.get(variableInState.type).updateOptions(variableInState);
|
||||
dispatch(switchToListMode());
|
||||
dispatch(removeVariable(toVariablePayload({ type: identifier.type, uuid: EMPTY_UUID }, { reIndex: false })));
|
||||
};
|
||||
};
|
||||
|
||||
export const changeVariableName = (identifier: VariableIdentifier, newName: string): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
let errorText = null;
|
||||
if (!newName.match(/^(?!__).*$/)) {
|
||||
errorText = "Template names cannot begin with '__', that's reserved for Grafana's global variables";
|
||||
}
|
||||
|
||||
if (!newName.match(/^\w+$/)) {
|
||||
errorText = 'Only word and digit characters are allowed in variable names';
|
||||
}
|
||||
|
||||
const variables = getVariables(getState());
|
||||
const stateVariables = variables.filter(v => v.name === newName && v.uuid !== identifier.uuid);
|
||||
|
||||
if (stateVariables.length) {
|
||||
errorText = 'Variable with the same name already exists';
|
||||
}
|
||||
|
||||
if (errorText) {
|
||||
dispatch(changeVariableNameFailed({ newName, errorText }));
|
||||
}
|
||||
|
||||
if (!errorText) {
|
||||
dispatch(changeVariableNameSucceeded(toVariablePayload(identifier, newName)));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const switchToNewMode = (): ThunkResult<void> => (dispatch, getState) => {
|
||||
const type: VariableType = 'query';
|
||||
const uuid = EMPTY_UUID;
|
||||
const global = false;
|
||||
const model = cloneDeep(variableAdapters.get(type).initialState);
|
||||
const index = Object.values(getState().templating.variables).length;
|
||||
const identifier = { type, uuid };
|
||||
dispatch(
|
||||
addVariable(
|
||||
toVariablePayload<AddVariable>(identifier, { global, model, index })
|
||||
)
|
||||
);
|
||||
dispatch(setIdInEditor({ id: identifier.uuid }));
|
||||
};
|
||||
|
||||
export const switchToEditMode = (identifier: VariableIdentifier): ThunkResult<void> => dispatch => {
|
||||
dispatch(setIdInEditor({ id: identifier.uuid }));
|
||||
};
|
||||
|
||||
export const switchToListMode = (): ThunkResult<void> => dispatch => {
|
||||
dispatch(clearIdInEditor());
|
||||
};
|
||||
195
public/app/features/templating/editor/reducer.test.ts
Normal file
195
public/app/features/templating/editor/reducer.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import {
|
||||
addVariableEditorError,
|
||||
changeVariableEditorExtended,
|
||||
changeVariableNameFailed,
|
||||
changeVariableNameSucceeded,
|
||||
clearIdInEditor,
|
||||
initialVariableEditorState,
|
||||
removeVariableEditorError,
|
||||
setIdInEditor,
|
||||
variableEditorMounted,
|
||||
variableEditorReducer,
|
||||
VariableEditorState,
|
||||
variableEditorUnMounted,
|
||||
} from './reducer';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
|
||||
describe('variableEditorReducer', () => {
|
||||
describe('when setIdInEditor is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { id: '0' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
|
||||
.whenActionIsDispatched(setIdInEditor(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
id: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clearIdInEditor is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState, id: '0' })
|
||||
.whenActionIsDispatched(clearIdInEditor())
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when variableEditorMounted is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { name: 'A name' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
|
||||
.whenActionIsDispatched(variableEditorMounted(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
name: 'A name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when variableEditorUnMounted is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const initialState = {
|
||||
...initialVariableEditorState,
|
||||
id: '0',
|
||||
name: 'A name',
|
||||
isValid: false,
|
||||
errors: { update: 'Something wrong' },
|
||||
extended: { prop: 1000 },
|
||||
};
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'textbox' });
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, initialState)
|
||||
.whenActionIsDispatched(variableEditorUnMounted(payload))
|
||||
.thenStateShouldEqual({ ...initialVariableEditorState });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableNameSucceeded is dispatched there are other errors', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const initialState = {
|
||||
...initialVariableEditorState,
|
||||
name: 'A duplicate name',
|
||||
isValid: false,
|
||||
errors: { name: 'Duplicate', update: 'Update failed' },
|
||||
};
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'textbox' }, 'New Name');
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, initialState)
|
||||
.whenActionIsDispatched(changeVariableNameSucceeded(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
isValid: false,
|
||||
errors: { update: 'Update failed' },
|
||||
name: 'New Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableNameSucceeded is dispatched there are no other errors', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const initialState = {
|
||||
...initialVariableEditorState,
|
||||
name: 'A duplicate name',
|
||||
isValid: false,
|
||||
errors: { name: 'Duplicate' },
|
||||
};
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'textbox' }, 'New Name');
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, initialState)
|
||||
.whenActionIsDispatched(changeVariableNameSucceeded(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
isValid: true,
|
||||
errors: {},
|
||||
name: 'New Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableNameFailed is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { newName: 'Duplicate name', errorText: 'Name is an duplicate' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
|
||||
.whenActionIsDispatched(changeVariableNameFailed(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
isValid: false,
|
||||
errors: { name: 'Name is an duplicate' },
|
||||
name: 'Duplicate name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when addVariableEditorError is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { errorProp: 'someProp', errorText: 'someProp failed' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
|
||||
.whenActionIsDispatched(addVariableEditorError(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
isValid: false,
|
||||
errors: { someProp: 'someProp failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when removeVariableEditorError is dispatched and there are other errors', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { errorProp: 'someProp' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, {
|
||||
...initialVariableEditorState,
|
||||
errors: { update: 'Update failed', someProp: 'someProp failed' },
|
||||
isValid: false,
|
||||
})
|
||||
.whenActionIsDispatched(removeVariableEditorError(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
isValid: false,
|
||||
errors: { update: 'Update failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when removeVariableEditorError is dispatched and there are no other errors', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { errorProp: 'someProp' };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, {
|
||||
...initialVariableEditorState,
|
||||
errors: { someProp: 'someProp failed' },
|
||||
isValid: false,
|
||||
})
|
||||
.whenActionIsDispatched(removeVariableEditorError(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableEditorExtended is dispatched', () => {
|
||||
it('then state should be correct ', () => {
|
||||
const payload = { propName: 'someProp', propValue: [{}] };
|
||||
reducerTester<VariableEditorState>()
|
||||
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
|
||||
.whenActionIsDispatched(changeVariableEditorExtended(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialVariableEditorState,
|
||||
extended: {
|
||||
someProp: [{}],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
85
public/app/features/templating/editor/reducer.ts
Normal file
85
public/app/features/templating/editor/reducer.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { VariablePayload } from '../state/types';
|
||||
|
||||
type VariableEditorExtension<ExtendedProps extends {} = {}> = { [P in keyof ExtendedProps]: ExtendedProps[P] };
|
||||
export interface VariableEditorState<ExtendedProps extends {} = {}> {
|
||||
id: string;
|
||||
name: string;
|
||||
errors: Record<string, string>;
|
||||
isValid: boolean;
|
||||
extended: VariableEditorExtension<ExtendedProps> | null;
|
||||
}
|
||||
|
||||
export const initialVariableEditorState: VariableEditorState = {
|
||||
id: '',
|
||||
isValid: true,
|
||||
errors: {},
|
||||
name: '',
|
||||
extended: null,
|
||||
};
|
||||
|
||||
const variableEditorReducerSlice = createSlice({
|
||||
name: 'templating/editor',
|
||||
initialState: initialVariableEditorState,
|
||||
reducers: {
|
||||
setIdInEditor: (state: VariableEditorState, action: PayloadAction<{ id: string }>) => {
|
||||
state.id = action.payload.id;
|
||||
},
|
||||
clearIdInEditor: (state: VariableEditorState, action: PayloadAction<undefined>) => {
|
||||
state.id = '';
|
||||
},
|
||||
variableEditorMounted: (state: VariableEditorState, action: PayloadAction<{ name: string }>) => {
|
||||
state.name = action.payload.name;
|
||||
},
|
||||
variableEditorUnMounted: (state: VariableEditorState, action: PayloadAction<VariablePayload>) => {
|
||||
return initialVariableEditorState;
|
||||
},
|
||||
changeVariableNameSucceeded: (state: VariableEditorState, action: PayloadAction<VariablePayload<string>>) => {
|
||||
state.name = action.payload.data;
|
||||
delete state.errors['name'];
|
||||
state.isValid = Object.keys(state.errors).length === 0;
|
||||
},
|
||||
changeVariableNameFailed: (
|
||||
state: VariableEditorState,
|
||||
action: PayloadAction<{ newName: string; errorText: string }>
|
||||
) => {
|
||||
state.name = action.payload.newName;
|
||||
state.errors.name = action.payload.errorText;
|
||||
state.isValid = Object.keys(state.errors).length === 0;
|
||||
},
|
||||
addVariableEditorError: (
|
||||
state: VariableEditorState,
|
||||
action: PayloadAction<{ errorProp: string; errorText: any }>
|
||||
) => {
|
||||
state.errors[action.payload.errorProp] = action.payload.errorText;
|
||||
state.isValid = Object.keys(state.errors).length === 0;
|
||||
},
|
||||
removeVariableEditorError: (state: VariableEditorState, action: PayloadAction<{ errorProp: string }>) => {
|
||||
delete state.errors[action.payload.errorProp];
|
||||
state.isValid = Object.keys(state.errors).length === 0;
|
||||
},
|
||||
changeVariableEditorExtended: (
|
||||
state: VariableEditorState,
|
||||
action: PayloadAction<{ propName: string; propValue: any }>
|
||||
) => {
|
||||
state.extended = {
|
||||
...state.extended,
|
||||
[action.payload.propName]: action.payload.propValue,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const variableEditorReducer = variableEditorReducerSlice.reducer;
|
||||
|
||||
export const {
|
||||
setIdInEditor,
|
||||
clearIdInEditor,
|
||||
changeVariableNameSucceeded,
|
||||
changeVariableNameFailed,
|
||||
variableEditorMounted,
|
||||
variableEditorUnMounted,
|
||||
changeVariableEditorExtended,
|
||||
addVariableEditorError,
|
||||
removeVariableEditorError,
|
||||
} = variableEditorReducerSlice.actions;
|
||||
12
public/app/features/templating/editor/types.ts
Normal file
12
public/app/features/templating/editor/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { VariableModel } from '../variable';
|
||||
|
||||
export interface OnPropChangeArguments<Model extends VariableModel = VariableModel> {
|
||||
propName: keyof Model;
|
||||
propValue: any;
|
||||
updateOptions?: boolean;
|
||||
}
|
||||
|
||||
export interface VariableEditorProps<Model extends VariableModel = VariableModel> {
|
||||
variable: Model;
|
||||
onPropChange: (args: OnPropChangeArguments<Model>) => void;
|
||||
}
|
||||
5
public/app/features/templating/guard.ts
Normal file
5
public/app/features/templating/guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VariableModel, QueryVariableModel } from './variable';
|
||||
|
||||
export const isQuery = (model: VariableModel): model is QueryVariableModel => {
|
||||
return model.type === 'query';
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ClickOutsideWrapper } from '@grafana/ui';
|
||||
import { VariableLink } from '../shared/VariableLink';
|
||||
import { VariableInput } from '../shared/VariableInput';
|
||||
import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, toggleAndFetchTag } from './actions';
|
||||
import { OptionsPickerState, showOptions, toggleAllOptions, toggleOption } from './reducer';
|
||||
import { VariableOption, VariableTag, VariableWithMultiSupport, VariableWithOptions } from '../../variable';
|
||||
import { VariableOptions } from '../shared/VariableOptions';
|
||||
import { isQuery } from '../../guard';
|
||||
import { VariablePickerProps } from '../types';
|
||||
|
||||
interface OwnProps extends VariablePickerProps<VariableWithMultiSupport> {}
|
||||
|
||||
interface ConnectedProps {
|
||||
picker: OptionsPickerState;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
showOptions: typeof showOptions;
|
||||
commitChangesToVariable: typeof commitChangesToVariable;
|
||||
toggleAllOptions: typeof toggleAllOptions;
|
||||
toggleOption: typeof toggleOption;
|
||||
toggleAndFetchTag: typeof toggleAndFetchTag;
|
||||
filterOrSearchOptions: typeof filterOrSearchOptions;
|
||||
navigateOptions: typeof navigateOptions;
|
||||
}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export class OptionsPickerUnconnected extends PureComponent<Props> {
|
||||
onShowOptions = () => this.props.showOptions(this.props.variable);
|
||||
onHideOptions = () => this.props.commitChangesToVariable();
|
||||
|
||||
onToggleOption = (option: VariableOption, clearOthers: boolean) => {
|
||||
this.props.toggleOption({
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { variable, picker } = this.props;
|
||||
const showOptions = picker.uuid === variable.uuid;
|
||||
|
||||
return (
|
||||
<div className="variable-link-wrapper">
|
||||
{this.renderLink(showOptions, variable)}
|
||||
{this.renderOptions(showOptions, picker)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLink(showOptions: boolean, variable: VariableWithMultiSupport) {
|
||||
if (showOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkText = getLinkText(variable);
|
||||
const tags = getSelectedTags(variable);
|
||||
|
||||
return <VariableLink text={linkText} tags={tags} onClick={this.onShowOptions} />;
|
||||
}
|
||||
|
||||
renderOptions(showOptions: boolean, picker: OptionsPickerState) {
|
||||
if (!showOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClickOutsideWrapper onClick={this.onHideOptions}>
|
||||
<VariableInput
|
||||
value={picker.queryValue}
|
||||
onChange={this.props.filterOrSearchOptions}
|
||||
onNavigate={this.props.navigateOptions}
|
||||
/>
|
||||
<VariableOptions
|
||||
values={picker.options}
|
||||
onToggle={this.onToggleOption}
|
||||
onToggleAll={this.props.toggleAllOptions}
|
||||
onToggleTag={this.props.toggleAndFetchTag}
|
||||
highlightIndex={picker.highlightIndex}
|
||||
multi={picker.multi}
|
||||
tags={picker.tags}
|
||||
selectedValues={picker.selectedValues}
|
||||
/>
|
||||
</ClickOutsideWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedTags = (variable: VariableWithOptions): VariableTag[] => {
|
||||
if (!isQuery(variable) || !Array.isArray(variable.tags)) {
|
||||
return [];
|
||||
}
|
||||
return variable.tags.filter(t => t.selected);
|
||||
};
|
||||
|
||||
const getLinkText = (variable: VariableWithOptions) => {
|
||||
const { current, options } = variable;
|
||||
|
||||
if (!current.tags || current.tags.length === 0) {
|
||||
if (Array.isArray(current.text)) {
|
||||
return current.text.join(' + ');
|
||||
}
|
||||
return current.text;
|
||||
}
|
||||
|
||||
// filer out values that are in selected tags
|
||||
const selectedAndNotInTag = options.filter(option => {
|
||||
if (!option.selected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!current || !current.tags || !current.tags.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < current.tags.length; i++) {
|
||||
const tag = current.tags[i];
|
||||
const foundIndex = tag?.values?.findIndex(v => v === option.value);
|
||||
if (foundIndex && foundIndex !== -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// convert values to text
|
||||
const currentTexts = selectedAndNotInTag.map(s => s.text);
|
||||
|
||||
// join texts
|
||||
const newLinkText = currentTexts.join(' + ');
|
||||
return newLinkText.length > 0 ? `${newLinkText} + ` : newLinkText;
|
||||
};
|
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||
showOptions,
|
||||
commitChangesToVariable,
|
||||
filterOrSearchOptions,
|
||||
toggleAllOptions,
|
||||
toggleOption,
|
||||
toggleAndFetchTag,
|
||||
navigateOptions,
|
||||
};
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
|
||||
picker: state.templating.optionsPicker,
|
||||
});
|
||||
|
||||
export const OptionsPicker = connect(mapStateToProps, mapDispatchToProps)(OptionsPickerUnconnected);
|
||||
OptionsPicker.displayName = 'OptionsPicker';
|
||||
@@ -0,0 +1,446 @@
|
||||
import { reduxTester } from '../../../../../test/core/redux/reduxTester';
|
||||
import { getTemplatingRootReducer } from '../../state/helpers';
|
||||
import { initDashboardTemplating } from '../../state/actions';
|
||||
import { TemplatingState } from '../../state/reducers';
|
||||
import { VariableHide, VariableSort, VariableRefresh, QueryVariableModel } from '../../variable';
|
||||
import {
|
||||
showOptions,
|
||||
hideOptions,
|
||||
toggleOption,
|
||||
updateSearchQuery,
|
||||
updateOptionsAndFilter,
|
||||
toggleTag,
|
||||
} from './reducer';
|
||||
import {
|
||||
navigateOptions,
|
||||
filterOrSearchOptions,
|
||||
commitChangesToVariable,
|
||||
toggleOptionByHighlight,
|
||||
toggleAndFetchTag,
|
||||
} from './actions';
|
||||
import { NavigationKey } from '../types';
|
||||
import { toVariablePayload } from '../../state/types';
|
||||
import { setCurrentVariableValue, changeVariableProp } from '../../state/sharedReducer';
|
||||
import { variableAdapters } from '../../adapters';
|
||||
import { createQueryVariableAdapter } from '../../query/adapter';
|
||||
|
||||
const datasource = {
|
||||
metricFindQuery: jest.fn(() => Promise.resolve([])),
|
||||
};
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const original = jest.requireActual('@grafana/runtime');
|
||||
|
||||
return {
|
||||
...original,
|
||||
getDataSourceSrv: jest.fn(() => ({
|
||||
get: () => datasource,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('options picker actions', () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key cancel', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ options: [createOption('A', 'A', true)] });
|
||||
|
||||
const clearOthers = false;
|
||||
const key = NavigationKey.cancel;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
const option = {
|
||||
...createOption('A'),
|
||||
selected: true,
|
||||
value: ['A'],
|
||||
tags: [] as any[],
|
||||
};
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [setCurrentValue, changeQueryValue, updateOption, hideAction] = actions;
|
||||
const expectedNumberOfActions = 4;
|
||||
|
||||
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(changeQueryValue).toEqual(
|
||||
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' }))
|
||||
);
|
||||
expect(updateOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(hideAction).toEqual(hideOptions());
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key select without clearOthers', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const option = createOption('A', 'A', true);
|
||||
const variable = createVariable({ options: [option], includeAll: false });
|
||||
|
||||
const clearOthers = false;
|
||||
const key = NavigationKey.select;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, false))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option, forceSelect: false, clearOthers }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key select with clearOthers', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const option = createOption('A', 'A', true);
|
||||
const variable = createVariable({ options: [option], includeAll: false });
|
||||
|
||||
const clearOthers = true;
|
||||
const key = NavigationKey.select;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option, forceSelect: false, clearOthers }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key select after highlighting the third option', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
|
||||
const clearOthers = true;
|
||||
const key = NavigationKey.select;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option: options[2], forceSelect: false, clearOthers }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key select after highlighting the second option', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
|
||||
const clearOthers = true;
|
||||
const key = NavigationKey.select;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigateOptions is dispatched with navigation key selectAndClose after highlighting the second option', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
|
||||
const clearOthers = false;
|
||||
const key = NavigationKey.selectAndClose;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers))
|
||||
.whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true);
|
||||
|
||||
const option = {
|
||||
...createOption('B'),
|
||||
selected: true,
|
||||
value: ['B'],
|
||||
tags: [] as any[],
|
||||
};
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction, setCurrentValue, changeQueryValue, updateOption, hideAction] = actions;
|
||||
const expectedNumberOfActions = 5;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers }));
|
||||
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(changeQueryValue).toEqual(
|
||||
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' }))
|
||||
);
|
||||
expect(updateOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(hideAction).toEqual(hideOptions());
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when filterOrSearchOptions is dispatched with simple filter', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
const filter = 'A';
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenAsyncActionIsDispatched(filterOrSearchOptions(filter), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateQueryValue, updateAndFilter] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(updateQueryValue).toEqual(updateSearchQuery(filter));
|
||||
expect(updateAndFilter).toEqual(updateOptionsAndFilter(variable.options));
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when commitChangesToVariable is dispatched with no changes', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenAsyncActionIsDispatched(commitChangesToVariable(), true);
|
||||
|
||||
const option = {
|
||||
...createOption(''),
|
||||
selected: true,
|
||||
value: [] as any[],
|
||||
tags: [] as any[],
|
||||
};
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [setCurrentValue, changeQueryValue, hideAction] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(changeQueryValue).toEqual(
|
||||
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' }))
|
||||
);
|
||||
expect(hideAction).toEqual(hideOptions());
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when commitChangesToVariable is dispatched with changes', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
const clearOthers = false;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(toggleOptionByHighlight(clearOthers))
|
||||
.whenAsyncActionIsDispatched(commitChangesToVariable(), true);
|
||||
|
||||
const option = {
|
||||
...createOption('A'),
|
||||
selected: true,
|
||||
value: ['A'],
|
||||
tags: [] as any[],
|
||||
};
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [setCurrentValue, changeQueryValue, updateOption, hideAction] = actions;
|
||||
const expectedNumberOfActions = 4;
|
||||
|
||||
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(changeQueryValue).toEqual(
|
||||
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' }))
|
||||
);
|
||||
expect(updateOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
expect(hideAction).toEqual(hideOptions());
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleOptionByHighlight is dispatched with changes', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const variable = createVariable({ options, includeAll: false });
|
||||
const clearOthers = false;
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
|
||||
.whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true);
|
||||
|
||||
const option = createOption('A');
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleOptionAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleOptionAction).toEqual(toggleOption({ option, forceSelect: false, clearOthers }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleAndFetchTag is dispatched with values', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const tag = createTag('tag', []);
|
||||
const variable = createVariable({ options, includeAll: false, tags: [tag] });
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenAsyncActionIsDispatched(toggleAndFetchTag(tag), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleTagAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleTagAction).toEqual(toggleTag(tag));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleAndFetchTag is dispatched without values', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const options = [createOption('A'), createOption('B'), createOption('C')];
|
||||
const tag = createTag('tag');
|
||||
const values = [createMetric('b')];
|
||||
const variable = createVariable({ options, includeAll: false, tags: [tag] });
|
||||
|
||||
datasource.metricFindQuery.mockReset();
|
||||
datasource.metricFindQuery.mockImplementation(() => Promise.resolve(values));
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(showOptions(variable))
|
||||
.whenAsyncActionIsDispatched(toggleAndFetchTag(tag), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [toggleTagAction] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(toggleTagAction).toEqual(toggleTag({ ...tag, values: ['b'] }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel {
|
||||
return {
|
||||
type: 'query',
|
||||
uuid: '0',
|
||||
global: false,
|
||||
current: createOption(''),
|
||||
options: [],
|
||||
query: 'options-query',
|
||||
name: 'Constant',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
index: 0,
|
||||
datasource: 'datasource',
|
||||
definition: '',
|
||||
sort: VariableSort.alphabeticalAsc,
|
||||
tags: [],
|
||||
tagsQuery: 'tags-query',
|
||||
tagValuesQuery: '',
|
||||
useTags: true,
|
||||
refresh: VariableRefresh.onDashboardLoad,
|
||||
regex: '',
|
||||
multi: true,
|
||||
includeAll: true,
|
||||
...(extend ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(text: string, value?: string, selected?: boolean) {
|
||||
const metric = createMetric(text);
|
||||
return {
|
||||
...metric,
|
||||
value: value ?? metric.value,
|
||||
selected: selected ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function createMetric(value: string) {
|
||||
return {
|
||||
value: value,
|
||||
text: value,
|
||||
};
|
||||
}
|
||||
|
||||
function createTag(name: string, values?: any[]) {
|
||||
return {
|
||||
selected: false,
|
||||
text: name,
|
||||
values,
|
||||
};
|
||||
}
|
||||
186
public/app/features/templating/pickers/OptionsPicker/actions.ts
Normal file
186
public/app/features/templating/pickers/OptionsPicker/actions.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import { StoreState, ThunkDispatch, ThunkResult } from 'app/types';
|
||||
import {
|
||||
containsSearchFilter,
|
||||
QueryVariableModel,
|
||||
VariableOption,
|
||||
VariableRefresh,
|
||||
VariableTag,
|
||||
VariableWithMultiSupport,
|
||||
VariableWithOptions,
|
||||
} from '../../variable';
|
||||
import { variableAdapters } from '../../adapters';
|
||||
import { getVariable } from '../../state/selectors';
|
||||
import { NavigationKey } from '../types';
|
||||
import {
|
||||
hideOptions,
|
||||
moveOptionsHighlight,
|
||||
OptionsPickerState,
|
||||
toggleOption,
|
||||
toggleTag,
|
||||
updateOptionsAndFilter,
|
||||
updateOptionsFromSearch,
|
||||
updateSearchQuery,
|
||||
} from './reducer';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { setCurrentVariableValue, changeVariableProp } from '../../state/sharedReducer';
|
||||
import { toVariablePayload } from '../../state/types';
|
||||
|
||||
export const navigateOptions = (key: NavigationKey, clearOthers: boolean): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
if (key === NavigationKey.cancel) {
|
||||
return await dispatch(commitChangesToVariable());
|
||||
}
|
||||
|
||||
if (key === NavigationKey.select) {
|
||||
return dispatch(toggleOptionByHighlight(clearOthers));
|
||||
}
|
||||
|
||||
if (key === NavigationKey.selectAndClose) {
|
||||
dispatch(toggleOptionByHighlight(clearOthers));
|
||||
return await dispatch(commitChangesToVariable());
|
||||
}
|
||||
|
||||
if (key === NavigationKey.moveDown) {
|
||||
return dispatch(moveOptionsHighlight(1));
|
||||
}
|
||||
|
||||
if (key === NavigationKey.moveUp) {
|
||||
return dispatch(moveOptionsHighlight(-1));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const filterOrSearchOptions = (searchQuery: string): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const { uuid } = getState().templating.optionsPicker;
|
||||
const { query, options } = getVariable<VariableWithOptions>(uuid!, getState());
|
||||
dispatch(updateSearchQuery(searchQuery));
|
||||
|
||||
if (containsSearchFilter(query)) {
|
||||
return searchForOptionsWithDebounce(dispatch, getState, searchQuery);
|
||||
}
|
||||
return dispatch(updateOptionsAndFilter(options));
|
||||
};
|
||||
};
|
||||
|
||||
export const commitChangesToVariable = (): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const picker = getState().templating.optionsPicker;
|
||||
const existing = getVariable<VariableWithMultiSupport>(picker.uuid, getState());
|
||||
const currentPayload = { option: mapToCurrent(picker) };
|
||||
const searchQueryPayload = { propName: 'queryValue', propValue: picker.queryValue };
|
||||
|
||||
dispatch(setCurrentVariableValue(toVariablePayload(existing, currentPayload)));
|
||||
dispatch(changeVariableProp(toVariablePayload(existing, searchQueryPayload)));
|
||||
const updated = getVariable<VariableWithMultiSupport>(picker.uuid, getState());
|
||||
|
||||
if (existing.current.text === updated.current.text) {
|
||||
return dispatch(hideOptions());
|
||||
}
|
||||
|
||||
const adapter = variableAdapters.get(updated.type);
|
||||
await adapter.setValue(updated, updated.current, true);
|
||||
return dispatch(hideOptions());
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleOptionByHighlight = (clearOthers: boolean): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
const { uuid, highlightIndex } = getState().templating.optionsPicker;
|
||||
const variable = getVariable<VariableWithMultiSupport>(uuid, getState());
|
||||
const option = variable.options[highlightIndex];
|
||||
dispatch(toggleOption({ option, forceSelect: false, clearOthers }));
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleAndFetchTag = (tag: VariableTag): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
if (Array.isArray(tag.values)) {
|
||||
return dispatch(toggleTag(tag));
|
||||
}
|
||||
|
||||
const values = await dispatch(fetchTagValues(tag.text.toString()));
|
||||
return dispatch(toggleTag({ ...tag, values }));
|
||||
};
|
||||
};
|
||||
|
||||
const fetchTagValues = (tagText: string): ThunkResult<Promise<string[]>> => {
|
||||
return async (dispatch, getState) => {
|
||||
const picker = getState().templating.optionsPicker;
|
||||
const variable = getVariable<QueryVariableModel>(picker.uuid, getState());
|
||||
|
||||
const datasource = await getDataSourceSrv().get(variable.datasource ?? '');
|
||||
const query = variable.tagValuesQuery.replace('$tag', tagText);
|
||||
const options = { range: getTimeRange(variable), variable };
|
||||
|
||||
if (!datasource.metricFindQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await datasource.metricFindQuery(query, options);
|
||||
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results.map(value => value.text);
|
||||
};
|
||||
};
|
||||
|
||||
const getTimeRange = (variable: QueryVariableModel) => {
|
||||
if (variable.refresh === VariableRefresh.onTimeRangeChanged) {
|
||||
return getTimeSrv().timeRange();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreState, searchQuery: string) => {
|
||||
try {
|
||||
const { uuid } = getState().templating.optionsPicker;
|
||||
const existing = getVariable<VariableWithOptions>(uuid, getState());
|
||||
|
||||
const adapter = variableAdapters.get(existing.type);
|
||||
await adapter.updateOptions(existing, searchQuery);
|
||||
|
||||
const updated = getVariable<VariableWithOptions>(uuid, getState());
|
||||
dispatch(updateOptionsFromSearch(updated.options));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const searchForOptionsWithDebounce = debounce(searchForOptions, 500);
|
||||
|
||||
function mapToCurrent(picker: OptionsPickerState): VariableOption | undefined {
|
||||
const { options, queryValue: searchQuery, multi } = picker;
|
||||
|
||||
if (options.length === 0 && searchQuery && searchQuery.length > 0) {
|
||||
return { text: searchQuery, value: searchQuery, selected: false };
|
||||
}
|
||||
|
||||
if (!multi) {
|
||||
return options.find(o => o.selected);
|
||||
}
|
||||
|
||||
const texts: string[] = [];
|
||||
const values: string[] = [];
|
||||
|
||||
for (const option of options) {
|
||||
if (!option.selected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
texts.push(option.text.toString());
|
||||
values.push(option.value.toString());
|
||||
}
|
||||
|
||||
return {
|
||||
value: values,
|
||||
text: texts.join(' + '),
|
||||
tags: picker.tags.filter(t => t.selected),
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import {
|
||||
hideOptions,
|
||||
initialState as optionsPickerInitialState,
|
||||
moveOptionsHighlight,
|
||||
optionsPickerReducer,
|
||||
OptionsPickerState,
|
||||
showOptions,
|
||||
toggleAllOptions,
|
||||
toggleOption,
|
||||
toggleTag,
|
||||
updateOptionsAndFilter,
|
||||
updateOptionsFromSearch,
|
||||
updateSearchQuery,
|
||||
} from './reducer';
|
||||
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
|
||||
import { QueryVariableModel, VariableTag } from '../../variable';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types';
|
||||
|
||||
const getVariableTestContext = (extend: Partial<OptionsPickerState>) => {
|
||||
return {
|
||||
initialState: {
|
||||
...optionsPickerInitialState,
|
||||
...extend,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('optionsPickerReducer', () => {
|
||||
describe('when toggleOption is dispatched', () => {
|
||||
const opsAll = [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const opsA = [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: true },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const opsB = [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: true },
|
||||
];
|
||||
const opsAB = [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: true },
|
||||
{ text: 'B', value: 'B', selected: true },
|
||||
];
|
||||
|
||||
const opA = { text: 'A', selected: true, value: 'A' };
|
||||
const opANot = { text: 'A', selected: false, value: 'A' };
|
||||
const opASel = [{ text: 'A', value: 'A', selected: true }];
|
||||
const opBSel = [{ text: 'B', value: 'B', selected: true }];
|
||||
const opAllSel = [{ text: 'All', value: '$__all', selected: true }];
|
||||
const opABSel = [
|
||||
{ text: 'A', value: 'A', selected: true },
|
||||
{ text: 'B', value: 'B', selected: true },
|
||||
];
|
||||
|
||||
const expectToggleOptionState = (args: {
|
||||
options: any;
|
||||
multi: any;
|
||||
forceSelect: any;
|
||||
clearOthers: any;
|
||||
option: any;
|
||||
expOps: any;
|
||||
expSel: any;
|
||||
}) => {
|
||||
const { initialState } = getVariableTestContext({ options: args.options, multi: args.multi });
|
||||
const payload = { forceSelect: args.forceSelect, clearOthers: args.clearOthers, option: args.option };
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(toggleOption(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
selectedValues: args.expSel,
|
||||
options: args.expOps,
|
||||
});
|
||||
};
|
||||
|
||||
describe('toggleOption for multi value variable', () => {
|
||||
const multi = true;
|
||||
describe('and options with All selected', () => {
|
||||
const options = opsAll;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${false} | ${opsAll} | ${opAllSel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('and options with A selected', () => {
|
||||
const options = opsA;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${false} | ${opsAll} | ${opAllSel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('and options with B selected', () => {
|
||||
const options = opsB;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opANot} | ${false} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opA} | ${false} | ${false} | ${opsB} | ${opBSel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('and options with A + B selected', () => {
|
||||
const options = opsAB;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opANot} | ${false} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsAB} | ${opABSel}
|
||||
${opA} | ${false} | ${false} | ${opsB} | ${opBSel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleOption for single value variable', () => {
|
||||
const multi = false;
|
||||
describe('and options with All selected', () => {
|
||||
const options = opsAll;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('and options with A selected', () => {
|
||||
const options = opsA;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('and options with B selected', () => {
|
||||
const options = opsB;
|
||||
it.each`
|
||||
option | forceSelect | clearOthers | expOps | expSel
|
||||
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${false} | ${opsA} | ${opASel}
|
||||
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
|
||||
${opA} | ${false} | ${true} | ${opsA} | ${opASel}
|
||||
`(
|
||||
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
|
||||
({ option, forceSelect, clearOthers, expOps, expSel }) =>
|
||||
expectToggleOptionState({
|
||||
options,
|
||||
multi,
|
||||
option,
|
||||
clearOthers,
|
||||
forceSelect,
|
||||
expOps,
|
||||
expSel,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showOptions is dispatched and picker has queryValue and variable has searchFilter', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = '*.__searchFilter';
|
||||
const queryValue = 'a search query';
|
||||
const selected = { text: 'All', value: '$__all', selected: true };
|
||||
const { initialState } = getVariableTestContext({});
|
||||
const payload = {
|
||||
type: 'query',
|
||||
query,
|
||||
options: [selected, { text: 'A', value: 'A', selected: false }, { text: 'B', value: 'B', selected: false }],
|
||||
multi: false,
|
||||
uuid: '0',
|
||||
queryValue,
|
||||
} as QueryVariableModel;
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(showOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: payload.options,
|
||||
queryValue,
|
||||
uuid: payload.uuid!,
|
||||
multi: payload.multi,
|
||||
selectedValues: [selected],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showOptions is dispatched and queryValue and variable has no searchFilter', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = '*.';
|
||||
const queryValue: any = null;
|
||||
const current = { text: ALL_VARIABLE_TEXT, selected: true, value: [ALL_VARIABLE_VALUE] };
|
||||
const options = [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const { initialState } = getVariableTestContext({});
|
||||
const payload = { type: 'query', uuid: '0', current, query, options, queryValue } as QueryVariableModel;
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(showOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
uuid: '0',
|
||||
queryValue: '',
|
||||
selectedValues: [
|
||||
{
|
||||
text: ALL_VARIABLE_TEXT,
|
||||
selected: true,
|
||||
value: ALL_VARIABLE_VALUE,
|
||||
},
|
||||
],
|
||||
options: options,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when hideOptions is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
queryValue: 'a search',
|
||||
highlightIndex: 1,
|
||||
uuid: '0',
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(hideOptions())
|
||||
.thenStateShouldEqual({ ...optionsPickerInitialState });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleTag is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
],
|
||||
options: [
|
||||
{ text: 'A', selected: false, value: 'A' },
|
||||
{ text: 'AA', selected: false, value: 'AA' },
|
||||
{ text: 'AAA', selected: false, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
});
|
||||
const payload: VariableTag = { text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] };
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(toggleTag(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: [
|
||||
{ text: 'A', selected: true, value: 'A' },
|
||||
{ text: 'AA', selected: true, value: 'AA' },
|
||||
{ text: 'AAA', selected: true, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'], valuesText: 'A + AA + AAA' },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
],
|
||||
selectedValues: [
|
||||
{ text: 'A', selected: true, value: 'A' },
|
||||
{ text: 'AA', selected: true, value: 'AA' },
|
||||
{ text: 'AAA', selected: true, value: 'AAA' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleTag is dispatched and ALL is previous selected', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
],
|
||||
options: [
|
||||
{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE },
|
||||
{ text: 'A', selected: false, value: 'A' },
|
||||
{ text: 'AA', selected: false, value: 'AA' },
|
||||
{ text: 'AAA', selected: false, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
});
|
||||
const payload: VariableTag = { text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] };
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(toggleTag(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: [
|
||||
{ text: ALL_VARIABLE_TEXT, selected: false, value: ALL_VARIABLE_VALUE },
|
||||
{ text: 'A', selected: true, value: 'A' },
|
||||
{ text: 'AA', selected: true, value: 'AA' },
|
||||
{ text: 'AAA', selected: true, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'], valuesText: 'A + AA + AAA' },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
],
|
||||
selectedValues: [
|
||||
{ text: 'A', selected: true, value: 'A' },
|
||||
{ text: 'AA', selected: true, value: 'AA' },
|
||||
{ text: 'AAA', selected: true, value: 'AAA' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleTag is dispatched and only the tag is previous selected', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
{ text: 'All D:s', selected: true, values: ['D'] },
|
||||
],
|
||||
options: [
|
||||
{ text: ALL_VARIABLE_TEXT, selected: false, value: ALL_VARIABLE_VALUE },
|
||||
{ text: 'A', selected: false, value: 'A' },
|
||||
{ text: 'AA', selected: false, value: 'AA' },
|
||||
{ text: 'AAA', selected: false, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
});
|
||||
const payload: VariableTag = { text: 'All D:s', selected: true, values: ['D'] };
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(toggleTag(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: [
|
||||
{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE },
|
||||
{ text: 'A', selected: false, value: 'A' },
|
||||
{ text: 'AA', selected: false, value: 'AA' },
|
||||
{ text: 'AAA', selected: false, value: 'AAA' },
|
||||
{ text: 'B', selected: false, value: 'B' },
|
||||
],
|
||||
tags: [
|
||||
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
|
||||
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
|
||||
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
|
||||
{ text: 'All D:s', selected: false, values: ['D'] },
|
||||
],
|
||||
selectedValues: [{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableHighlightIndex is dispatched with -1 and highlightIndex is 0', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({ highlightIndex: 0 });
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(moveOptionsHighlight(-1))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
highlightIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableHighlightIndex is dispatched with -1 and highlightIndex is 1', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
highlightIndex: 1,
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(moveOptionsHighlight(-1))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
highlightIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableHighlightIndex is dispatched with 1 and highlightIndex is same as options.length', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
highlightIndex: 1,
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(moveOptionsHighlight(1))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
highlightIndex: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableHighlightIndex is dispatched with 1 and highlightIndex is below options.length', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
highlightIndex: 0,
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(moveOptionsHighlight(1))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
highlightIndex: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when toggleAllOptions is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext({
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
multi: true,
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(toggleAllOptions())
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: true },
|
||||
{ text: 'B', value: 'B', selected: true },
|
||||
],
|
||||
selectedValues: [
|
||||
{ text: 'A', value: 'A', selected: true },
|
||||
{ text: 'B', value: 'B', selected: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateOptionsAndFilter is dispatched and searchFilter exists', () => {
|
||||
it('then state should be correct', () => {
|
||||
const searchQuery = 'A';
|
||||
|
||||
const options = [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
|
||||
const { initialState } = getVariableTestContext({
|
||||
queryValue: searchQuery,
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateOptionsAndFilter(options))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
],
|
||||
selectedValues: [{ text: 'All', value: '$__all', selected: true }],
|
||||
queryValue: searchQuery,
|
||||
highlightIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateOptionsFromSearch is dispatched and variable has searchFilter', () => {
|
||||
it('then state should be correct', () => {
|
||||
const searchQuery = '__searchFilter';
|
||||
const options = [
|
||||
{ text: 'All', value: '$__all', selected: true },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const { initialState } = getVariableTestContext({
|
||||
queryValue: searchQuery,
|
||||
});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateOptionsFromSearch(options))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
options: options,
|
||||
selectedValues: [{ text: 'All', value: '$__all', selected: true }],
|
||||
highlightIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateSearchQuery is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const searchQuery = 'A';
|
||||
const { initialState } = getVariableTestContext({});
|
||||
|
||||
reducerTester<OptionsPickerState>()
|
||||
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateSearchQuery(searchQuery))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
queryValue: searchQuery,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
208
public/app/features/templating/pickers/OptionsPicker/reducer.ts
Normal file
208
public/app/features/templating/pickers/OptionsPicker/reducer.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { containsSearchFilter, VariableOption, VariableTag, VariableWithMultiSupport } from '../../variable';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types';
|
||||
import { isQuery } from '../../guard';
|
||||
import { applyStateChanges } from '../../../../core/utils/applyStateChanges';
|
||||
|
||||
export interface ToggleOption {
|
||||
option: VariableOption;
|
||||
forceSelect: boolean;
|
||||
clearOthers: boolean;
|
||||
}
|
||||
|
||||
export interface OptionsPickerState {
|
||||
uuid: string;
|
||||
selectedValues: VariableOption[];
|
||||
selectedTags: VariableTag[];
|
||||
queryValue: string | null;
|
||||
highlightIndex: number;
|
||||
tags: VariableTag[];
|
||||
options: VariableOption[];
|
||||
multi: boolean;
|
||||
}
|
||||
|
||||
export const initialState: OptionsPickerState = {
|
||||
uuid: '',
|
||||
highlightIndex: -1,
|
||||
queryValue: null,
|
||||
selectedTags: [],
|
||||
selectedValues: [],
|
||||
tags: [],
|
||||
options: [],
|
||||
multi: false,
|
||||
};
|
||||
|
||||
const getTags = (model: VariableWithMultiSupport) => {
|
||||
if (isQuery(model) && Array.isArray(model.tags)) {
|
||||
return cloneDeep(model.tags);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const updateSelectedValues = (state: OptionsPickerState): OptionsPickerState => {
|
||||
state.selectedValues = state.options.filter(o => o.selected);
|
||||
return state;
|
||||
};
|
||||
|
||||
const applyLimit = (options: VariableOption[]): VariableOption[] => {
|
||||
if (!Array.isArray(options)) {
|
||||
return [];
|
||||
}
|
||||
return options.slice(0, Math.min(options.length, 1000));
|
||||
};
|
||||
|
||||
const updateDefaultSelection = (state: OptionsPickerState): OptionsPickerState => {
|
||||
const { options } = state;
|
||||
if (options.length > 0 && options.filter(o => o.selected).length === 0) {
|
||||
options[0].selected = true;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const optionsPickerSlice = createSlice({
|
||||
name: 'templating/optionsPicker',
|
||||
initialState,
|
||||
reducers: {
|
||||
showOptions: (state, action: PayloadAction<VariableWithMultiSupport>): OptionsPickerState => {
|
||||
const { query, options, multi } = action.payload;
|
||||
|
||||
state.highlightIndex = -1;
|
||||
state.options = cloneDeep(options);
|
||||
state.tags = getTags(action.payload);
|
||||
state.multi = multi ?? false;
|
||||
state.uuid = action.payload.uuid!;
|
||||
state.queryValue = '';
|
||||
|
||||
if (isQuery(action.payload)) {
|
||||
const { queryValue } = action.payload;
|
||||
const queryHasSearchFilter = containsSearchFilter(query);
|
||||
state.queryValue = queryHasSearchFilter && queryValue ? queryValue : '';
|
||||
}
|
||||
|
||||
return applyStateChanges(state, updateSelectedValues);
|
||||
},
|
||||
hideOptions: (state, action: PayloadAction): OptionsPickerState => {
|
||||
return { ...initialState };
|
||||
},
|
||||
toggleOption: (state, action: PayloadAction<ToggleOption>): OptionsPickerState => {
|
||||
const { option, forceSelect, clearOthers } = action.payload;
|
||||
const { multi } = state;
|
||||
const newOptions: VariableOption[] = state.options.map(o => {
|
||||
if (o.value !== option.value) {
|
||||
let selected = o.selected;
|
||||
if (o.text === ALL_VARIABLE_TEXT || option.text === ALL_VARIABLE_TEXT) {
|
||||
selected = false;
|
||||
} else if (!multi) {
|
||||
selected = false;
|
||||
} else if (clearOthers) {
|
||||
selected = false;
|
||||
}
|
||||
o.selected = selected;
|
||||
return o;
|
||||
}
|
||||
o.selected = forceSelect ? true : multi ? !option.selected : true;
|
||||
return o;
|
||||
});
|
||||
|
||||
state.options = newOptions;
|
||||
return applyStateChanges(state, updateDefaultSelection, updateSelectedValues);
|
||||
},
|
||||
toggleTag: (state, action: PayloadAction<VariableTag>): OptionsPickerState => {
|
||||
const tag = action.payload;
|
||||
const values = tag.values || [];
|
||||
const selected = !tag.selected;
|
||||
|
||||
state.tags = state.tags.map(t => {
|
||||
if (t.text !== tag.text) {
|
||||
return t;
|
||||
}
|
||||
|
||||
t.selected = selected;
|
||||
t.values = values;
|
||||
|
||||
if (selected) {
|
||||
t.valuesText = values.join(' + ');
|
||||
} else {
|
||||
delete t.valuesText;
|
||||
}
|
||||
|
||||
return t;
|
||||
});
|
||||
|
||||
state.options = state.options.map(option => {
|
||||
if (option.value === ALL_VARIABLE_VALUE && selected === true) {
|
||||
option.selected = false;
|
||||
}
|
||||
|
||||
if (values.indexOf(option.value) === -1) {
|
||||
return option;
|
||||
}
|
||||
|
||||
option.selected = selected;
|
||||
return option;
|
||||
});
|
||||
|
||||
return applyStateChanges(state, updateDefaultSelection, updateSelectedValues);
|
||||
},
|
||||
moveOptionsHighlight: (state, action: PayloadAction<number>): OptionsPickerState => {
|
||||
let nextIndex = state.highlightIndex + action.payload;
|
||||
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = 0;
|
||||
} else if (nextIndex >= state.options.length) {
|
||||
nextIndex = state.options.length - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
highlightIndex: nextIndex,
|
||||
};
|
||||
},
|
||||
toggleAllOptions: (state, action: PayloadAction): OptionsPickerState => {
|
||||
const selected = !state.options.find(option => option.selected);
|
||||
state.options = state.options.map(option => ({
|
||||
...option,
|
||||
selected,
|
||||
}));
|
||||
|
||||
return applyStateChanges(state, updateSelectedValues);
|
||||
},
|
||||
updateSearchQuery: (state, action: PayloadAction<string>): OptionsPickerState => {
|
||||
state.queryValue = action.payload;
|
||||
return state;
|
||||
},
|
||||
updateOptionsAndFilter: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
|
||||
const searchQuery = (state.queryValue ?? '').toLowerCase();
|
||||
|
||||
state.options = applyLimit(action.payload);
|
||||
state.highlightIndex = 0;
|
||||
state.options = state.options.filter(option => {
|
||||
const text = Array.isArray(option.text) ? option.text.toString() : option.text;
|
||||
return text.toLowerCase().indexOf(searchQuery) !== -1;
|
||||
});
|
||||
|
||||
return applyStateChanges(state, updateSelectedValues);
|
||||
},
|
||||
updateOptionsFromSearch: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
|
||||
state.options = applyLimit(action.payload);
|
||||
state.highlightIndex = 0;
|
||||
|
||||
return applyStateChanges(state, updateSelectedValues);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleOption,
|
||||
showOptions,
|
||||
hideOptions,
|
||||
toggleTag,
|
||||
moveOptionsHighlight,
|
||||
toggleAllOptions,
|
||||
updateSearchQuery,
|
||||
updateOptionsAndFilter,
|
||||
updateOptionsFromSearch,
|
||||
} = optionsPickerSlice.actions;
|
||||
|
||||
export const optionsPickerReducer = optionsPickerSlice.reducer;
|
||||
33
public/app/features/templating/pickers/PickerRenderer.tsx
Normal file
33
public/app/features/templating/pickers/PickerRenderer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { FunctionComponent, useMemo } from 'react';
|
||||
import { VariableHide, VariableModel } from '../variable';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { variableAdapters } from '../adapters';
|
||||
|
||||
interface Props {
|
||||
variable: VariableModel;
|
||||
}
|
||||
|
||||
export const PickerRenderer: FunctionComponent<Props> = props => {
|
||||
const PickerToRender = useMemo(() => variableAdapters.get(props.variable.type).picker, [props.variable]);
|
||||
const labelOrName = useMemo(() => props.variable.label || props.variable.name, [props.variable]);
|
||||
|
||||
if (!props.variable) {
|
||||
return <div>Couldn't load variable</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
{props.variable.hide === VariableHide.dontHide && (
|
||||
<label
|
||||
className="gf-form-label template-variable"
|
||||
aria-label={e2e.pages.Dashboard.SubMenu.selectors.submenuItemLabels(labelOrName)}
|
||||
>
|
||||
{labelOrName}
|
||||
</label>
|
||||
)}
|
||||
{props.variable.hide !== VariableHide.hideVariable && PickerToRender && (
|
||||
<PickerToRender variable={props.variable} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
public/app/features/templating/pickers/index.ts
Normal file
1
public/app/features/templating/pickers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OptionsPicker } from './OptionsPicker/OptionsPicker';
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { trim } from 'lodash';
|
||||
import { NavigationKey } from '../types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (value: string) => void;
|
||||
onNavigate: (key: NavigationKey, clearOthers: boolean) => void;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export class VariableInput extends PureComponent<Props> {
|
||||
onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (NavigationKey[event.keyCode]) {
|
||||
const clearOthers = event.ctrlKey || event.metaKey || event.shiftKey;
|
||||
this.props.onNavigate(event.keyCode as NavigationKey, clearOthers);
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (this.shouldUpdateValue(event.target.value)) {
|
||||
this.props.onChange(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
private shouldUpdateValue(value: string) {
|
||||
return trim(value ?? '').length > 0 || trim(this.props.value ?? '').length > 0;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<input
|
||||
ref={instance => {
|
||||
if (instance) {
|
||||
instance.focus();
|
||||
instance.setAttribute('style', `width:${Math.max(instance.width, 80)}px`);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.props.value ?? ''}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { getTagColorsFromName } from '@grafana/ui';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { VariableTag } from '../../variable';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
tags: VariableTag[];
|
||||
}
|
||||
export class VariableLink extends PureComponent<Props> {
|
||||
onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.props.onClick();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { tags = [], text } = this.props;
|
||||
|
||||
return (
|
||||
<a
|
||||
onClick={this.onClick}
|
||||
className="variable-value-link"
|
||||
aria-label={e2e.pages.Dashboard.SubMenu.selectors.submenuItemValueDropDownValueLinkTexts(`${text}`)}
|
||||
>
|
||||
{text}
|
||||
{tags.map(tag => {
|
||||
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
|
||||
return (
|
||||
<span bs-tooltip="tag.valuesText" data-placement="bottom" key={`${tag.text}`}>
|
||||
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
|
||||
<i className="fa fa-tag"></i> {tag.text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<i className="fa fa-caret-down" style={{ fontSize: '12px' }}></i>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { getTagColorsFromName, Tooltip } from '@grafana/ui';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { VariableOption, VariableTag } from '../../variable';
|
||||
|
||||
export interface Props {
|
||||
multi: boolean;
|
||||
values: VariableOption[];
|
||||
selectedValues: VariableOption[];
|
||||
tags: VariableTag[];
|
||||
highlightIndex: number;
|
||||
onToggle: (option: VariableOption, clearOthers: boolean) => void;
|
||||
onToggleAll: () => void;
|
||||
onToggleTag: (tag: VariableTag) => void;
|
||||
}
|
||||
|
||||
export class VariableOptions extends PureComponent<Props> {
|
||||
onToggle = (option: VariableOption) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const clearOthers = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
this.handleEvent(event);
|
||||
this.props.onToggle(option, clearOthers);
|
||||
};
|
||||
|
||||
onToggleAll = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
this.handleEvent(event);
|
||||
this.props.onToggleAll();
|
||||
};
|
||||
|
||||
onToggleTag = (tag: VariableTag) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
this.handleEvent(event);
|
||||
this.props.onToggleTag(tag);
|
||||
};
|
||||
|
||||
handleEvent(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { multi, values, tags } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${multi ? 'variable-value-dropdown multi' : 'variable-value-dropdown single'}`}
|
||||
aria-label={e2e.pages.Dashboard.SubMenu.selectors.submenuItemValueDropDownDropDown}
|
||||
>
|
||||
<div className="variable-options-wrapper">
|
||||
<div className="variable-options-column">
|
||||
{this.renderMultiToggle()}
|
||||
{values.map((option, index) => this.renderOption(option, index))}
|
||||
</div>
|
||||
{this.renderTags(tags)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderTags(tags: VariableTag[]) {
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="variable-options-column">
|
||||
<div className="variable-options-column-header text-center">Tags</div>
|
||||
{tags.map(tag => this.renderTag(tag))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderTag(tag: VariableTag) {
|
||||
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
|
||||
|
||||
return (
|
||||
<a
|
||||
key={`${tag.text}`}
|
||||
className={`${tag.selected ? 'variable-option-tag pointer selected' : 'variable-option-tag pointer'}`}
|
||||
onClick={this.onToggleTag(tag)}
|
||||
>
|
||||
<span className="fa fa-fw variable-option-icon"></span>
|
||||
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
|
||||
{tag.text} <i className="fa fa-tag"></i>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderOption(option: VariableOption, index: number) {
|
||||
const { highlightIndex } = this.props;
|
||||
const selectClass = option.selected ? 'variable-option pointer selected' : 'variable-option pointer';
|
||||
const highlightClass = index === highlightIndex ? `${selectClass} highlighted` : selectClass;
|
||||
|
||||
return (
|
||||
<a key={`${option.value}`} className={highlightClass} onClick={this.onToggle(option)}>
|
||||
<span className="variable-option-icon"></span>
|
||||
<span aria-label={e2e.pages.Dashboard.SubMenu.selectors.submenuItemValueDropDownOptionTexts(`${option.text}`)}>
|
||||
{option.text}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderMultiToggle() {
|
||||
const { multi, selectedValues } = this.props;
|
||||
|
||||
if (!multi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={'Clear selections'} placement={'top'}>
|
||||
<a
|
||||
className={`${
|
||||
selectedValues.length > 1
|
||||
? 'variable-options-column-header many-selected'
|
||||
: 'variable-options-column-header'
|
||||
}`}
|
||||
onClick={this.onToggleAll}
|
||||
data-placement="top"
|
||||
>
|
||||
<span className="variable-option-icon"></span>
|
||||
Selected ({selectedValues.length})
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
public/app/features/templating/pickers/types.ts
Normal file
13
public/app/features/templating/pickers/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { VariableModel } from '../variable';
|
||||
|
||||
export interface VariablePickerProps<Model extends VariableModel = VariableModel> {
|
||||
variable: Model;
|
||||
}
|
||||
|
||||
export enum NavigationKey {
|
||||
moveUp = 38,
|
||||
moveDown = 40,
|
||||
select = 32,
|
||||
cancel = 27,
|
||||
selectAndClose = 13,
|
||||
}
|
||||
309
public/app/features/templating/query/QueryVariableEditor.tsx
Normal file
309
public/app/features/templating/query/QueryVariableEditor.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { ChangeEvent, PureComponent } from 'react';
|
||||
import { e2e } from '@grafana/e2e';
|
||||
import { FormLabel, Switch } from '@grafana/ui';
|
||||
|
||||
import templateSrv from '../template_srv';
|
||||
import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor';
|
||||
import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../variable';
|
||||
import { QueryVariableEditorState } from './reducer';
|
||||
import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions';
|
||||
import { VariableEditorState } from '../editor/reducer';
|
||||
import { OnPropChangeArguments, VariableEditorProps } from '../editor/types';
|
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { StoreState } from '../../../types';
|
||||
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
|
||||
import { toVariableIdentifier } from '../state/types';
|
||||
|
||||
export interface OwnProps extends VariableEditorProps<QueryVariableModel> {}
|
||||
|
||||
interface ConnectedProps {
|
||||
editor: VariableEditorState<QueryVariableEditorState>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
initQueryVariableEditor: typeof initQueryVariableEditor;
|
||||
changeQueryVariableDataSource: typeof changeQueryVariableDataSource;
|
||||
changeQueryVariableQuery: typeof changeQueryVariableQuery;
|
||||
}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export interface State {
|
||||
regex: string | null;
|
||||
tagsQuery: string | null;
|
||||
tagValuesQuery: string | null;
|
||||
}
|
||||
|
||||
export class QueryVariableEditorUnConnected extends PureComponent<Props, State> {
|
||||
state: State = {
|
||||
regex: null,
|
||||
tagsQuery: null,
|
||||
tagValuesQuery: null,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
await this.props.initQueryVariableEditor(toVariableIdentifier(this.props.variable));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>): void {
|
||||
if (prevProps.variable.datasource !== this.props.variable.datasource) {
|
||||
this.props.changeQueryVariableDataSource(
|
||||
toVariableIdentifier(this.props.variable),
|
||||
this.props.variable.datasource
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedDataSourceValue = (): string => {
|
||||
if (!this.props.editor.extended?.dataSources.length) {
|
||||
return '';
|
||||
}
|
||||
const foundItem = this.props.editor.extended?.dataSources.find(ds => ds.value === this.props.variable.datasource);
|
||||
const value = foundItem ? foundItem.value : this.props.editor.extended?.dataSources[0].value;
|
||||
return value ?? '';
|
||||
};
|
||||
|
||||
onDataSourceChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.props.onPropChange({ propName: 'query', propValue: '' });
|
||||
this.props.onPropChange({ propName: 'datasource', propValue: event.target.value });
|
||||
};
|
||||
|
||||
onQueryChange = async (query: any, definition: string) => {
|
||||
this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition);
|
||||
};
|
||||
|
||||
onRegExChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ regex: event.target.value });
|
||||
};
|
||||
|
||||
onRegExBlur = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({ propName: 'regex', propValue: event.target.value, updateOptions: true });
|
||||
};
|
||||
|
||||
onTagsQueryChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ tagsQuery: event.target.value });
|
||||
};
|
||||
|
||||
onTagsQueryBlur = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({ propName: 'tagsQuery', propValue: event.target.value, updateOptions: true });
|
||||
};
|
||||
|
||||
onTagValuesQueryChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ tagValuesQuery: event.target.value });
|
||||
};
|
||||
|
||||
onTagValuesQueryBlur = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({ propName: 'tagValuesQuery', propValue: event.target.value, updateOptions: true });
|
||||
};
|
||||
|
||||
onRefreshChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.props.onPropChange({ propName: 'refresh', propValue: parseInt(event.target.value, 10) });
|
||||
};
|
||||
|
||||
onSortChange = async (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.props.onPropChange({ propName: 'sort', propValue: parseInt(event.target.value, 10), updateOptions: true });
|
||||
};
|
||||
|
||||
onSelectionOptionsChange = async ({ propValue, propName }: OnPropChangeArguments<VariableWithMultiSupport>) => {
|
||||
this.props.onPropChange({ propName, propValue, updateOptions: true });
|
||||
};
|
||||
|
||||
onUseTagsChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.onPropChange({ propName: 'useTags', propValue: event.target.checked, updateOptions: true });
|
||||
};
|
||||
|
||||
render() {
|
||||
const VariableQueryEditor = this.props.editor.extended?.VariableQueryEditor;
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-group">
|
||||
<h5 className="section-heading">Query Options</h5>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-21">
|
||||
<span className="gf-form-label width-10">Data source</span>
|
||||
<div className="gf-form-select-wrapper max-width-14">
|
||||
<select
|
||||
className="gf-form-input"
|
||||
value={this.getSelectedDataSourceValue()}
|
||||
onChange={this.onDataSourceChange}
|
||||
required
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.queryOptionsDataSourceSelect
|
||||
}
|
||||
>
|
||||
{this.props.editor.extended?.dataSources.length &&
|
||||
this.props.editor.extended?.dataSources.map(ds => (
|
||||
<option key={ds.value ?? ''} value={ds.value ?? ''} label={ds.name}>
|
||||
{ds.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gf-form max-width-22">
|
||||
<FormLabel width={10} tooltip={'When to update the values of this variable.'}>
|
||||
Refresh
|
||||
</FormLabel>
|
||||
<div className="gf-form-select-wrapper width-15">
|
||||
<select
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.refresh}
|
||||
onChange={this.onRefreshChange}
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.queryOptionsRefreshSelect
|
||||
}
|
||||
>
|
||||
<option label="Never" value={VariableRefresh.never}>
|
||||
Never
|
||||
</option>
|
||||
<option label="On Dashboard Load" value={VariableRefresh.onDashboardLoad}>
|
||||
On Dashboard Load
|
||||
</option>
|
||||
<option label="On Time Range Change" value={VariableRefresh.onTimeRangeChanged}>
|
||||
On Time Range Change
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{VariableQueryEditor && this.props.editor.extended?.dataSource && (
|
||||
<VariableQueryEditor
|
||||
datasource={this.props.editor.extended?.dataSource}
|
||||
query={this.props.variable.query}
|
||||
templateSrv={templateSrv}
|
||||
onChange={this.onQueryChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="gf-form">
|
||||
<FormLabel
|
||||
width={10}
|
||||
tooltip={'Optional, if you want to extract part of a series name or metric node segment.'}
|
||||
>
|
||||
Regex
|
||||
</FormLabel>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
placeholder="/.*-(.*)-.*/"
|
||||
value={this.state.regex ?? this.props.variable.regex}
|
||||
onChange={this.onRegExChange}
|
||||
onBlur={this.onRegExBlur}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.queryOptionsRegExInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form max-width-21">
|
||||
<FormLabel width={10} tooltip={'How to sort the values of this variable.'}>
|
||||
Sort
|
||||
</FormLabel>
|
||||
<div className="gf-form-select-wrapper max-width-14">
|
||||
<select
|
||||
className="gf-form-input"
|
||||
value={this.props.variable.sort}
|
||||
onChange={this.onSortChange}
|
||||
aria-label={e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.queryOptionsSortSelect}
|
||||
>
|
||||
<option label="Disabled" value={VariableSort.disabled}>
|
||||
Disabled
|
||||
</option>
|
||||
<option label="Alphabetical (asc)" value={VariableSort.alphabeticalAsc}>
|
||||
Alphabetical (asc)
|
||||
</option>
|
||||
<option label="Alphabetical (desc)" value={VariableSort.alphabeticalDesc}>
|
||||
Alphabetical (desc)
|
||||
</option>
|
||||
<option label="Numerical (asc)" value={VariableSort.numericalAsc}>
|
||||
Numerical (asc)
|
||||
</option>
|
||||
<option label="Numerical (desc)" value={VariableSort.numericalDesc}>
|
||||
Numerical (desc)
|
||||
</option>
|
||||
<option
|
||||
label="Alphabetical (case-insensitive, asc)"
|
||||
value={VariableSort.alphabeticalCaseInsensitiveAsc}
|
||||
>
|
||||
Alphabetical (case-insensitive, asc)
|
||||
</option>
|
||||
<option
|
||||
label="Alphabetical (case-insensitive, desc)"
|
||||
value={VariableSort.alphabeticalCaseInsensitiveDesc}
|
||||
>
|
||||
Alphabetical (case-insensitive, desc)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectionOptionsEditor variable={this.props.variable} onPropChange={this.onSelectionOptionsChange} />
|
||||
|
||||
<div className="gf-form-group">
|
||||
<h5>Value groups/tags (Experimental feature)</h5>
|
||||
<div
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.valueGroupsTagsEnabledSwitch
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
label-class="width-10"
|
||||
checked={this.props.variable.useTags}
|
||||
onChange={this.onUseTagsChange}
|
||||
/>
|
||||
</div>
|
||||
{this.props.variable.useTags && (
|
||||
<>
|
||||
<div className="gf-form last">
|
||||
<span className="gf-form-label width-10">Tags query</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.state.tagsQuery ?? this.props.variable.tagsQuery}
|
||||
placeholder="metric name or tags query"
|
||||
onChange={this.onTagsQueryChange}
|
||||
onBlur={this.onTagsQueryBlur}
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors.valueGroupsTagsTagsQueryInput
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<li className="gf-form-label width-10">Tag values query</li>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={this.state.tagValuesQuery ?? this.props.variable.tagValuesQuery}
|
||||
placeholder="apps.$tag.*"
|
||||
onChange={this.onTagValuesQueryChange}
|
||||
onBlur={this.onTagValuesQueryBlur}
|
||||
aria-label={
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.selectors
|
||||
.valueGroupsTagsTagsValuesQueryInput
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, ownProps) => ({
|
||||
editor: state.templating.editor as VariableEditorState<QueryVariableEditorState>,
|
||||
});
|
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||
initQueryVariableEditor,
|
||||
changeQueryVariableDataSource,
|
||||
changeQueryVariableQuery,
|
||||
};
|
||||
|
||||
export const QueryVariableEditor = connectWithStore(
|
||||
QueryVariableEditorUnConnected,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
);
|
||||
572
public/app/features/templating/query/actions.test.ts
Normal file
572
public/app/features/templating/query/actions.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createQueryVariableAdapter } from './adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { getTemplatingRootReducer } from '../state/helpers';
|
||||
import { QueryVariableModel, VariableHide, VariableSort, VariableRefresh } from '../variable';
|
||||
import { toVariablePayload, ALL_VARIABLE_VALUE, ALL_VARIABLE_TEXT } from '../state/types';
|
||||
import { setCurrentVariableValue, changeVariableProp } from '../state/sharedReducer';
|
||||
import { initDashboardTemplating } from '../state/actions';
|
||||
import { TemplatingState } from '../state/reducers';
|
||||
import {
|
||||
updateQueryVariableOptions,
|
||||
initQueryVariableEditor,
|
||||
changeQueryVariableDataSource,
|
||||
changeQueryVariableQuery,
|
||||
} from './actions';
|
||||
import { updateVariableOptions, updateVariableTags } from './reducer';
|
||||
import {
|
||||
setIdInEditor,
|
||||
removeVariableEditorError,
|
||||
addVariableEditorError,
|
||||
changeVariableEditorExtended,
|
||||
} from '../editor/reducer';
|
||||
import DefaultVariableQueryEditor from '../DefaultVariableQueryEditor';
|
||||
import { expect } from 'test/lib/common';
|
||||
|
||||
const mocks: Record<string, any> = {
|
||||
datasource: {
|
||||
metricFindQuery: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
datasourceSrv: {
|
||||
getMetricSources: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
pluginLoader: {
|
||||
importDataSourcePlugin: jest.fn().mockResolvedValue({ components: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../plugins/datasource_srv', () => ({
|
||||
getDatasourceSrv: jest.fn(() => ({
|
||||
get: jest.fn((name: string) => mocks[name]),
|
||||
getMetricSources: () => mocks.datasourceSrv.getMetricSources(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../plugins/plugin_loader', () => ({
|
||||
importDataSourcePlugin: () => mocks.pluginLoader.importDataSourcePlugin(),
|
||||
}));
|
||||
|
||||
describe('query actions', () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched for variable with tags and includeAll', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true });
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
|
||||
|
||||
mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateOptions, updateTags, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, optionsMetrics)));
|
||||
expect(updateTags).toEqual(updateVariableTags(toVariablePayload(variable, tagsMetrics)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched for variable with tags', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: false });
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
|
||||
|
||||
mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
const option = createOption('A');
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateOptions, updateTags, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, optionsMetrics)));
|
||||
expect(updateTags).toEqual(updateVariableTags(toVariablePayload(variable, tagsMetrics)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched for variable without both tags and includeAll', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: false, useTags: false });
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
|
||||
mockDatasourceMetrics(variable, optionsMetrics, []);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
const option = createOption('A');
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateOptions, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, optionsMetrics)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched for variable with includeAll but without tags', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
|
||||
mockDatasourceMetrics(variable, optionsMetrics, []);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateOptions, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, optionsMetrics)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched for variable open in editor', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
|
||||
mockDatasourceMetrics(variable, optionsMetrics, []);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(setIdInEditor({ id: variable.uuid }))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [clearErrors, updateOptions, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' }));
|
||||
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, optionsMetrics)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateQueryVariableOptions is dispatched and fails for variable open in editor', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const error = { message: 'failed to fetch metrics' };
|
||||
|
||||
mocks[variable.datasource].metricFindQuery = jest.fn(() => Promise.reject(error));
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenActionIsDispatched(setIdInEditor({ id: variable.uuid }))
|
||||
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [clearErrors, errorOccurred] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(errorOccurred).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message }));
|
||||
expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initQueryVariableEditor is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const defaultMetricSource = { name: '', value: '', meta: {}, sort: '' };
|
||||
const testMetricSource = { name: 'test', value: 'test', meta: {}, sort: '' };
|
||||
const editor = {};
|
||||
|
||||
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([testMetricSource]);
|
||||
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
|
||||
components: { VariableQueryEditor: editor },
|
||||
});
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasources, setDatasource, setEditor] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(updateDatasources).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultMetricSource, testMetricSource] })
|
||||
);
|
||||
expect(setDatasource).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
|
||||
);
|
||||
expect(setEditor).toEqual(changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: editor }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initQueryVariableEditor is dispatched and metricsource without value is available', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const defaultMetricSource = { name: '', value: '', meta: {}, sort: '' };
|
||||
const testMetricSource = { name: 'test', value: null as string, meta: {}, sort: '' };
|
||||
const editor = {};
|
||||
|
||||
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([testMetricSource]);
|
||||
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
|
||||
components: { VariableQueryEditor: editor },
|
||||
});
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasources, setDatasource, setEditor] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(updateDatasources).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultMetricSource] })
|
||||
);
|
||||
expect(setDatasource).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
|
||||
);
|
||||
expect(setEditor).toEqual(changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: editor }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initQueryVariableEditor is dispatched and no metric sources was found', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ includeAll: true, useTags: false });
|
||||
const defaultDatasource = { name: '', value: '', meta: {}, sort: '' };
|
||||
const editor = {};
|
||||
|
||||
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([]);
|
||||
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
|
||||
components: { VariableQueryEditor: editor },
|
||||
});
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasources, setDatasource, setEditor] = actions;
|
||||
const expectedNumberOfActions = 3;
|
||||
|
||||
expect(updateDatasources).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultDatasource] })
|
||||
);
|
||||
expect(setDatasource).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
|
||||
);
|
||||
expect(setEditor).toEqual(changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: editor }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initQueryVariableEditor is dispatched and variable dont have datasource', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ datasource: undefined });
|
||||
const ds = { name: '', value: '', meta: {}, sort: '' };
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasources] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(updateDatasources).toEqual(changeVariableEditorExtended({ propName: 'dataSources', propValue: [ds] }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableDataSource is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ datasource: 'other' });
|
||||
const editor = {};
|
||||
|
||||
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
|
||||
components: { VariableQueryEditor: editor },
|
||||
});
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableDataSource(toVariablePayload(variable), 'datasource'), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasource, updateEditor] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(updateDatasource).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks.datasource })
|
||||
);
|
||||
expect(updateEditor).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: editor })
|
||||
);
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableDataSource is dispatched and editor is not configured', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ datasource: 'other' });
|
||||
const editor = DefaultVariableQueryEditor;
|
||||
|
||||
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
|
||||
components: {},
|
||||
});
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableDataSource(toVariablePayload(variable), 'datasource'), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [updateDatasource, updateEditor] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(updateDatasource).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks.datasource })
|
||||
);
|
||||
expect(updateEditor).toEqual(
|
||||
changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: editor })
|
||||
);
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableQuery is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
|
||||
const variable = createVariable({ datasource: 'datasource', useTags: true, includeAll: true });
|
||||
|
||||
const query = '$datasource';
|
||||
const definition = 'depends on datasource variable';
|
||||
|
||||
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, tagsMetrics);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true);
|
||||
|
||||
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(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, optionsMetrics)));
|
||||
expect(updateTags).toEqual(updateVariableTags(toVariablePayload(variable, tagsMetrics)));
|
||||
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableQuery is dispatched for variable without tags', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: true });
|
||||
|
||||
const query = '$datasource';
|
||||
const definition = 'depends on datasource variable';
|
||||
|
||||
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true);
|
||||
|
||||
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(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, optionsMetrics)));
|
||||
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableQuery is dispatched for variable without tags and all', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const optionsMetrics = [createMetric('A'), createMetric('B')];
|
||||
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: false });
|
||||
const query = '$datasource';
|
||||
const definition = 'depends on datasource variable';
|
||||
|
||||
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []);
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true);
|
||||
|
||||
const option = createOption('A');
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(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, optionsMetrics)));
|
||||
expect(setOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeQueryVariableQuery is dispatched with invalid query', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: false });
|
||||
const query = `$${variable.name}`;
|
||||
const definition = 'depends on datasource variable';
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true);
|
||||
|
||||
const errorText = 'Query cannot contain a reference to itself. Variable: $' + variable.name;
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [editorError] = actions;
|
||||
const expectedNumberOfActions = 1;
|
||||
|
||||
expect(editorError).toEqual(addVariableEditorError({ errorProp: 'query', errorText }));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockDatasourceMetrics(variable: QueryVariableModel, optionsMetrics: any[], tagsMetrics: any[]) {
|
||||
const metrics: Record<string, any[]> = {
|
||||
[variable.query]: optionsMetrics,
|
||||
[variable.tagsQuery]: tagsMetrics,
|
||||
};
|
||||
|
||||
const { metricFindQuery } = mocks[variable.datasource];
|
||||
|
||||
metricFindQuery.mockReset();
|
||||
metricFindQuery.mockImplementation((query: string) => Promise.resolve(metrics[query] ?? []));
|
||||
}
|
||||
|
||||
function createVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel {
|
||||
return {
|
||||
type: 'query',
|
||||
uuid: '0',
|
||||
global: false,
|
||||
current: createOption(''),
|
||||
options: [],
|
||||
query: 'options-query',
|
||||
name: 'Constant',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
index: 0,
|
||||
datasource: 'datasource',
|
||||
definition: '',
|
||||
sort: VariableSort.alphabeticalAsc,
|
||||
tags: [],
|
||||
tagsQuery: 'tags-query',
|
||||
tagValuesQuery: '',
|
||||
useTags: true,
|
||||
refresh: VariableRefresh.onDashboardLoad,
|
||||
regex: '',
|
||||
multi: true,
|
||||
includeAll: true,
|
||||
...(extend ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(text: string, value?: string) {
|
||||
const metric = createMetric(text);
|
||||
return {
|
||||
...metric,
|
||||
value: value ?? metric.value,
|
||||
selected: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createMetric(value: string) {
|
||||
return {
|
||||
value: value,
|
||||
text: value,
|
||||
};
|
||||
}
|
||||
115
public/app/features/templating/query/actions.ts
Normal file
115
public/app/features/templating/query/actions.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { AppEvents, DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data';
|
||||
|
||||
import { validateVariableSelectionState } from '../state/actions';
|
||||
import { QueryVariableModel, VariableRefresh } from '../variable';
|
||||
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 '../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';
|
||||
|
||||
export const updateQueryVariableOptions = (
|
||||
identifier: VariableIdentifier,
|
||||
searchFilter?: string
|
||||
): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variableInState = getVariable<QueryVariableModel>(identifier.uuid!, getState());
|
||||
try {
|
||||
if (getState().templating.editor.id === variableInState.uuid) {
|
||||
dispatch(removeVariableEditorError({ errorProp: 'update' }));
|
||||
}
|
||||
const dataSource = await getDatasourceSrv().get(variableInState.datasource ?? '');
|
||||
const queryOptions: any = { range: undefined, variable: variableInState, searchFilter };
|
||||
if (variableInState.refresh === VariableRefresh.onTimeRangeChanged) {
|
||||
queryOptions.range = getTimeSrv().timeRange();
|
||||
}
|
||||
|
||||
if (!dataSource.metricFindQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await dataSource.metricFindQuery(variableInState.query, queryOptions);
|
||||
await dispatch(updateVariableOptions(toVariablePayload(variableInState, results)));
|
||||
|
||||
if (variableInState.useTags) {
|
||||
const tagResults = await dataSource.metricFindQuery(variableInState.tagsQuery, queryOptions);
|
||||
await dispatch(updateVariableTags(toVariablePayload(variableInState, tagResults)));
|
||||
}
|
||||
|
||||
await dispatch(validateVariableSelectionState(toVariableIdentifier(variableInState)));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.data && err.data.message) {
|
||||
err.message = err.data.message;
|
||||
}
|
||||
if (getState().templating.editor.id === variableInState.uuid) {
|
||||
dispatch(addVariableEditorError({ errorProp: 'update', errorText: err.message }));
|
||||
}
|
||||
appEvents.emit(AppEvents.alertError, [
|
||||
'Templating',
|
||||
'Template variables could not be initialized: ' + err.message,
|
||||
]);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const initQueryVariableEditor = (identifier: VariableIdentifier): ThunkResult<void> => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
const dataSources: DataSourceSelectItem[] = getDatasourceSrv()
|
||||
.getMetricSources()
|
||||
.filter(ds => !ds.meta.mixed && ds.value !== null);
|
||||
|
||||
const defaultDatasource: DataSourceSelectItem = { name: '', value: '', meta: {} as DataSourcePluginMeta, sort: '' };
|
||||
const allDataSources = [defaultDatasource].concat(dataSources);
|
||||
dispatch(changeVariableEditorExtended({ propName: 'dataSources', propValue: allDataSources }));
|
||||
|
||||
const variable = getVariable<QueryVariableModel>(identifier.uuid!, getState());
|
||||
if (!variable.datasource) {
|
||||
return;
|
||||
}
|
||||
dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource));
|
||||
};
|
||||
|
||||
export const changeQueryVariableDataSource = (
|
||||
identifier: VariableIdentifier,
|
||||
name: string | null
|
||||
): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const dataSource = await getDatasourceSrv().get(name ?? '');
|
||||
const dsPlugin = await importDataSourcePlugin(dataSource.meta!);
|
||||
const VariableQueryEditor = dsPlugin.components.VariableQueryEditor ?? DefaultVariableQueryEditor;
|
||||
dispatch(changeVariableEditorExtended({ propName: 'dataSource', propValue: dataSource }));
|
||||
dispatch(changeVariableEditorExtended({ propName: 'VariableQueryEditor', propValue: VariableQueryEditor }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const changeQueryVariableQuery = (
|
||||
identifier: VariableIdentifier,
|
||||
query: any,
|
||||
definition: string
|
||||
): ThunkResult<void> => async (dispatch, getState) => {
|
||||
const variableInState = getVariable<QueryVariableModel>(identifier.uuid!, getState());
|
||||
if (typeof query === 'string' && query.match(new RegExp('\\$' + variableInState.name + '(/| |$)'))) {
|
||||
const errorText = 'Query cannot contain a reference to itself. Variable: $' + variableInState.name;
|
||||
dispatch(addVariableEditorError({ errorProp: 'query', errorText }));
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
49
public/app/features/templating/query/adapter.ts
Normal file
49
public/app/features/templating/query/adapter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { containsVariable, QueryVariableModel, VariableRefresh } from '../variable';
|
||||
import { initialQueryVariableModelState, queryVariableReducer } from './reducer';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions';
|
||||
import { VariableAdapter } from '../adapters';
|
||||
import { OptionsPicker } from '../pickers';
|
||||
import { QueryVariableEditor } from './QueryVariableEditor';
|
||||
import { updateQueryVariableOptions } from './actions';
|
||||
import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types';
|
||||
|
||||
export const createQueryVariableAdapter = (): VariableAdapter<QueryVariableModel> => {
|
||||
return {
|
||||
description: 'Variable values are fetched from a datasource query',
|
||||
label: 'Query',
|
||||
initialState: initialQueryVariableModelState,
|
||||
reducer: queryVariableReducer,
|
||||
picker: OptionsPicker,
|
||||
editor: QueryVariableEditor,
|
||||
dependsOn: (variable, variableToTest) => {
|
||||
return containsVariable(variable.query, variable.datasource, variable.regex, variableToTest.name);
|
||||
},
|
||||
setValue: async (variable, option, emitChanges = false) => {
|
||||
await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges));
|
||||
},
|
||||
setValueFromUrl: async (variable, urlValue) => {
|
||||
await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue));
|
||||
},
|
||||
updateOptions: async (variable, searchFilter) => {
|
||||
await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter));
|
||||
},
|
||||
getSaveModel: variable => {
|
||||
const { index, uuid, initLock, global, queryValue, ...rest } = cloneDeep(variable);
|
||||
// remove options
|
||||
if (variable.refresh !== VariableRefresh.never) {
|
||||
return { ...rest, options: [] };
|
||||
}
|
||||
|
||||
return rest;
|
||||
},
|
||||
getValueForUrl: variable => {
|
||||
if (variable.current.text === ALL_VARIABLE_TEXT) {
|
||||
return ALL_VARIABLE_TEXT;
|
||||
}
|
||||
return variable.current.value;
|
||||
},
|
||||
};
|
||||
};
|
||||
161
public/app/features/templating/query/reducer.test.ts
Normal file
161
public/app/features/templating/query/reducer.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import { queryVariableReducer, updateVariableOptions, updateVariableTags } from './reducer';
|
||||
import { QueryVariableModel, VariableOption } from '../variable';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { VariablesState } from '../state/variablesReducer';
|
||||
import { getVariableTestContext } from '../state/helpers';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { createQueryVariableAdapter } from './adapter';
|
||||
|
||||
describe('queryVariableReducer', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is true', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: true });
|
||||
const options: VariableOption[] = [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, options);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is false', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: false });
|
||||
const options: VariableOption[] = [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, options);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is true and payload is an empty array', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: true });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, []);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [{ text: 'All', value: '$__all', selected: false }],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is false and payload is an empty array', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: false });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, []);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [{ text: 'None', value: '', selected: false, isNone: true }],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is true and regex is set', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: true, regex: '/.*(a).*/i' });
|
||||
const options: VariableOption[] = [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, options);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableOptions is dispatched and includeAll is false and regex is set', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter, { includeAll: false, regex: '/.*(a).*/i' });
|
||||
const options: VariableOption[] = [
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
];
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, options);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [{ text: 'A', value: 'A', selected: false }],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateVariableTags is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const { initialState } = getVariableTestContext(adapter);
|
||||
const tags: any[] = [{ text: 'A' }, { text: 'B' }];
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, tags);
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(queryVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(updateVariableTags(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
tags: [
|
||||
{ text: 'A', selected: false },
|
||||
{ text: 'B', selected: false },
|
||||
],
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
165
public/app/features/templating/query/reducer.ts
Normal file
165
public/app/features/templating/query/reducer.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import _ from 'lodash';
|
||||
import { DataSourceApi, DataSourceSelectItem, stringToJsRegex } from '@grafana/data';
|
||||
|
||||
import {
|
||||
QueryVariableModel,
|
||||
VariableHide,
|
||||
VariableOption,
|
||||
VariableRefresh,
|
||||
VariableSort,
|
||||
VariableTag,
|
||||
} from '../variable';
|
||||
import templateSrv from '../template_srv';
|
||||
import {
|
||||
ALL_VARIABLE_TEXT,
|
||||
ALL_VARIABLE_VALUE,
|
||||
EMPTY_UUID,
|
||||
getInstanceState,
|
||||
NONE_VARIABLE_TEXT,
|
||||
NONE_VARIABLE_VALUE,
|
||||
VariablePayload,
|
||||
} from '../state/types';
|
||||
import { ComponentType } from 'react';
|
||||
import { VariableQueryProps } from '../../../types';
|
||||
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
|
||||
|
||||
export interface QueryVariableEditorState {
|
||||
VariableQueryEditor: ComponentType<VariableQueryProps> | null;
|
||||
dataSources: DataSourceSelectItem[];
|
||||
dataSource: DataSourceApi | null;
|
||||
}
|
||||
|
||||
export const initialQueryVariableModelState: QueryVariableModel = {
|
||||
uuid: EMPTY_UUID,
|
||||
global: false,
|
||||
index: -1,
|
||||
type: 'query',
|
||||
name: '',
|
||||
label: null,
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
datasource: null,
|
||||
query: '',
|
||||
regex: '',
|
||||
sort: VariableSort.disabled,
|
||||
refresh: VariableRefresh.never,
|
||||
multi: false,
|
||||
includeAll: false,
|
||||
allValue: null,
|
||||
options: [],
|
||||
current: {} as VariableOption,
|
||||
tags: [],
|
||||
useTags: false,
|
||||
tagsQuery: '',
|
||||
tagValuesQuery: '',
|
||||
definition: '',
|
||||
initLock: null,
|
||||
};
|
||||
|
||||
const sortVariableValues = (options: any[], sortOrder: VariableSort) => {
|
||||
if (sortOrder === VariableSort.disabled) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const sortType = Math.ceil(sortOrder / 2);
|
||||
const reverseSort = sortOrder % 2 === 0;
|
||||
|
||||
if (sortType === 1) {
|
||||
options = _.sortBy(options, 'text');
|
||||
} else if (sortType === 2) {
|
||||
options = _.sortBy(options, opt => {
|
||||
const matches = opt.text.match(/.*?(\d+).*/);
|
||||
if (!matches || matches.length < 2) {
|
||||
return -1;
|
||||
} else {
|
||||
return parseInt(matches[1], 10);
|
||||
}
|
||||
});
|
||||
} else if (sortType === 3) {
|
||||
options = _.sortBy(options, opt => {
|
||||
return _.toLower(opt.text);
|
||||
});
|
||||
}
|
||||
|
||||
if (reverseSort) {
|
||||
options = options.reverse();
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const metricNamesToVariableValues = (variableRegEx: string, sort: VariableSort, metricNames: any[]) => {
|
||||
let regex, i, matches;
|
||||
let options: VariableOption[] = [];
|
||||
|
||||
if (variableRegEx) {
|
||||
regex = stringToJsRegex(templateSrv.replace(variableRegEx, {}, 'regex'));
|
||||
}
|
||||
for (i = 0; i < metricNames.length; i++) {
|
||||
const item = metricNames[i];
|
||||
let text = item.text === undefined || item.text === null ? item.value : item.text;
|
||||
|
||||
let value = item.value === undefined || item.value === null ? item.text : item.value;
|
||||
|
||||
if (_.isNumber(value)) {
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
if (_.isNumber(text)) {
|
||||
text = text.toString();
|
||||
}
|
||||
|
||||
if (regex) {
|
||||
matches = regex.exec(value);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
value = matches[1];
|
||||
text = matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
options.push({ text: text, value: value, selected: false });
|
||||
}
|
||||
|
||||
options = _.uniqBy(options, 'value');
|
||||
return sortVariableValues(options, sort);
|
||||
};
|
||||
|
||||
export const queryVariableSlice = createSlice({
|
||||
name: 'templating/query',
|
||||
initialState: initialVariablesState,
|
||||
reducers: {
|
||||
updateVariableOptions: (state: VariablesState, action: PayloadAction<VariablePayload<any[]>>) => {
|
||||
const results = action.payload.data;
|
||||
const instanceState = getInstanceState<QueryVariableModel>(state, action.payload.uuid);
|
||||
const { regex, includeAll, sort } = instanceState;
|
||||
const options = metricNamesToVariableValues(regex, sort, results);
|
||||
|
||||
if (includeAll) {
|
||||
options.unshift({ text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false });
|
||||
}
|
||||
if (!options.length) {
|
||||
options.push({ text: NONE_VARIABLE_TEXT, value: NONE_VARIABLE_VALUE, isNone: true, selected: false });
|
||||
}
|
||||
|
||||
instanceState.options = options;
|
||||
},
|
||||
updateVariableTags: (state: VariablesState, action: PayloadAction<VariablePayload<any[]>>) => {
|
||||
const instanceState = getInstanceState<QueryVariableModel>(state, action.payload.uuid);
|
||||
const results = action.payload.data;
|
||||
const tags: VariableTag[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
tags.push({ text: results[i].text, selected: false });
|
||||
}
|
||||
|
||||
instanceState.tags = tags;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const queryVariableReducer = queryVariableSlice.reducer;
|
||||
|
||||
export const { updateVariableOptions, updateVariableTags } = queryVariableSlice.actions;
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
VariableType,
|
||||
variableTypes,
|
||||
} from './variable';
|
||||
import { stringToJsRegex } from '@grafana/data';
|
||||
import { DataSourceApi, stringToJsRegex } from '@grafana/data';
|
||||
import DatasourceSrv from '../plugins/datasource_srv';
|
||||
import { TemplateSrv } from './template_srv';
|
||||
import { VariableSrv } from './variable_srv';
|
||||
@@ -25,10 +25,10 @@ function getNoneOption(): VariableOption {
|
||||
export class QueryVariable implements QueryVariableModel, VariableActions {
|
||||
type: VariableType;
|
||||
name: string;
|
||||
label: string;
|
||||
label: string | null;
|
||||
hide: VariableHide;
|
||||
skipUrlSync: boolean;
|
||||
datasource: string;
|
||||
datasource: string | null;
|
||||
query: string;
|
||||
regex: string;
|
||||
sort: VariableSort;
|
||||
@@ -43,6 +43,7 @@ export class QueryVariable implements QueryVariableModel, VariableActions {
|
||||
tags: VariableTag[];
|
||||
definition: string;
|
||||
allValue: string;
|
||||
index: number;
|
||||
|
||||
defaults: QueryVariableModel = {
|
||||
type: 'query',
|
||||
@@ -65,6 +66,7 @@ export class QueryVariable implements QueryVariableModel, VariableActions {
|
||||
tagsQuery: '',
|
||||
tagValuesQuery: '',
|
||||
definition: '',
|
||||
index: -1,
|
||||
};
|
||||
|
||||
/** @ngInject */
|
||||
@@ -109,8 +111,8 @@ export class QueryVariable implements QueryVariableModel, VariableActions {
|
||||
|
||||
updateOptions(searchFilter?: string) {
|
||||
return this.datasourceSrv
|
||||
.get(this.datasource)
|
||||
.then(ds => this.updateOptionsFromMetricFindQuery(ds, searchFilter))
|
||||
.get(this.datasource ?? '')
|
||||
.then((ds: DataSourceApi) => this.updateOptionsFromMetricFindQuery(ds, searchFilter))
|
||||
.then(this.updateTags.bind(this))
|
||||
.then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, this));
|
||||
}
|
||||
@@ -132,7 +134,7 @@ export class QueryVariable implements QueryVariableModel, VariableActions {
|
||||
}
|
||||
|
||||
getValuesForTag(tagKey: string) {
|
||||
return this.datasourceSrv.get(this.datasource).then(datasource => {
|
||||
return this.datasourceSrv.get(this.datasource ?? '').then((datasource: DataSourceApi) => {
|
||||
const query = this.tagValuesQuery.replace('$tag', tagKey);
|
||||
return this.metricFindQuery(datasource, query).then((results: any) => {
|
||||
return _.map(results, value => {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { QueryVariable } from '../query_variable';
|
||||
import DatasourceSrv from '../../plugins/datasource_srv';
|
||||
import { TemplateSrv } from '../template_srv';
|
||||
import { VariableSrv } from '../variable_srv';
|
||||
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||
|
||||
describe('QueryVariable', () => {
|
||||
describe('when creating from model', () => {
|
||||
it('should set defaults', () => {
|
||||
const variable = new QueryVariable({}, null, null, null, null);
|
||||
const variable = new QueryVariable(
|
||||
{},
|
||||
(null as unknown) as DatasourceSrv,
|
||||
(null as unknown) as TemplateSrv,
|
||||
(null as unknown) as VariableSrv,
|
||||
(null as unknown) as TimeSrv
|
||||
);
|
||||
expect(variable.datasource).toBe(null);
|
||||
expect(variable.refresh).toBe(0);
|
||||
expect(variable.sort).toBe(0);
|
||||
@@ -15,7 +25,13 @@ describe('QueryVariable', () => {
|
||||
});
|
||||
|
||||
it('get model should copy changes back to model', () => {
|
||||
const variable = new QueryVariable({}, null, null, null, null);
|
||||
const variable = new QueryVariable(
|
||||
{},
|
||||
(null as unknown) as DatasourceSrv,
|
||||
(null as unknown) as TemplateSrv,
|
||||
(null as unknown) as VariableSrv,
|
||||
(null as unknown) as TimeSrv
|
||||
);
|
||||
variable.options = [{ text: 'test', value: '', selected: false }];
|
||||
variable.datasource = 'google';
|
||||
variable.regex = 'asd';
|
||||
@@ -30,7 +46,13 @@ describe('QueryVariable', () => {
|
||||
});
|
||||
|
||||
it('if refresh != 0 then remove options in presisted mode', () => {
|
||||
const variable = new QueryVariable({}, null, null, null, null);
|
||||
const variable = new QueryVariable(
|
||||
{},
|
||||
(null as unknown) as DatasourceSrv,
|
||||
(null as unknown) as TemplateSrv,
|
||||
(null as unknown) as VariableSrv,
|
||||
(null as unknown) as TimeSrv
|
||||
);
|
||||
variable.options = [{ text: 'test', value: '', selected: false }];
|
||||
variable.refresh = 1;
|
||||
|
||||
@@ -40,7 +62,13 @@ describe('QueryVariable', () => {
|
||||
});
|
||||
|
||||
describe('can convert and sort metric names', () => {
|
||||
const variable = new QueryVariable({}, null, null, null, null);
|
||||
const variable = new QueryVariable(
|
||||
{},
|
||||
(null as unknown) as DatasourceSrv,
|
||||
(null as unknown) as TemplateSrv,
|
||||
(null as unknown) as VariableSrv,
|
||||
(null as unknown) as TimeSrv
|
||||
);
|
||||
let input: any;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -213,13 +213,13 @@ const scenarios: GetSearchFilterScopedVarScenario[] = [
|
||||
{
|
||||
query: 'abc.$__searchFilter',
|
||||
wildcardChar: '',
|
||||
options: null,
|
||||
options: null as any,
|
||||
expected: { __searchFilter: { value: '', text: '' } },
|
||||
},
|
||||
{
|
||||
query: 'abc.$__searchFilter',
|
||||
wildcardChar: '*',
|
||||
options: null,
|
||||
options: null as any,
|
||||
expected: { __searchFilter: { value: '*', text: '' } },
|
||||
},
|
||||
// testing the no search filter at all
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('VariableSrv init', function(this: any) {
|
||||
});
|
||||
}
|
||||
|
||||
['query', 'interval', 'custom', 'datasource'].forEach(type => {
|
||||
['interval', 'custom', 'datasource'].forEach(type => {
|
||||
describeInitScenario('when setting ' + type + ' variable via url', (scenario: any) => {
|
||||
scenario.setup(() => {
|
||||
scenario.variables = [
|
||||
@@ -103,6 +103,7 @@ describe('VariableSrv init', function(this: any) {
|
||||
});
|
||||
});
|
||||
|
||||
// this test will moved to redux tests instead
|
||||
describe('given dependent variables', () => {
|
||||
const variableList = [
|
||||
{
|
||||
@@ -170,7 +171,7 @@ describe('VariableSrv init', function(this: any) {
|
||||
scenario.variables = [
|
||||
{
|
||||
name: 'apps',
|
||||
type: 'query',
|
||||
type: 'custom',
|
||||
multi: true,
|
||||
current: { text: 'Val1', value: 'val1' },
|
||||
options: [
|
||||
@@ -206,7 +207,7 @@ describe('VariableSrv init', function(this: any) {
|
||||
scenario.variables = [
|
||||
{
|
||||
name: 'apps',
|
||||
type: 'query',
|
||||
type: 'custom',
|
||||
multi: true,
|
||||
},
|
||||
];
|
||||
@@ -228,7 +229,7 @@ describe('VariableSrv init', function(this: any) {
|
||||
scenario.variables = [
|
||||
{
|
||||
name: 'apps',
|
||||
type: 'query',
|
||||
type: 'custom',
|
||||
multi: true,
|
||||
current: { text: 'Val1', value: 'val1' },
|
||||
options: [
|
||||
|
||||
270
public/app/features/templating/state/actions.test.ts
Normal file
270
public/app/features/templating/state/actions.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { UrlQueryMap } from '@grafana/runtime';
|
||||
|
||||
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer, variableMockBuilder } from './helpers';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createQueryVariableAdapter } from '../query/adapter';
|
||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||
import { createTextBoxVariableAdapter } from '../textbox/adapter';
|
||||
import { createConstantVariableAdapter } from '../constant/adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { TemplatingState } from 'app/features/templating/state/reducers';
|
||||
import { initDashboardTemplating, processVariables, setOptionFromUrl, validateVariableSelectionState } from './actions';
|
||||
import { addInitLock, addVariable, removeInitLock, resolveInitLock, setCurrentVariableValue } from './sharedReducer';
|
||||
import { toVariableIdentifier, toVariablePayload } from './types';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
describe('shared actions', () => {
|
||||
describe('when initDashboardTemplating is dispatched', () => {
|
||||
it('then correct actions are dispatched', () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||
const query = variableMockBuilder('query').create();
|
||||
const constant = variableMockBuilder('constant').create();
|
||||
const datasource = variableMockBuilder('datasource').create();
|
||||
const custom = variableMockBuilder('custom').create();
|
||||
const textbox = variableMockBuilder('textbox').create();
|
||||
const list = [query, constant, datasource, custom, textbox];
|
||||
|
||||
reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.thenDispatchedActionPredicateShouldEqual(dispatchedActions => {
|
||||
expect(dispatchedActions.length).toEqual(8);
|
||||
expect(dispatchedActions[0]).toEqual(
|
||||
addVariable(toVariablePayload(query, { global: false, index: 0, model: query }))
|
||||
);
|
||||
expect(dispatchedActions[1]).toEqual(
|
||||
addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))
|
||||
);
|
||||
expect(dispatchedActions[2]).toEqual(
|
||||
addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom }))
|
||||
);
|
||||
expect(dispatchedActions[3]).toEqual(
|
||||
addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox }))
|
||||
);
|
||||
|
||||
// because uuid are dynamic we need to get the uuid from the resulting state
|
||||
// an alternative would be to add our own uuids in the model above instead
|
||||
expect(dispatchedActions[4]).toEqual(
|
||||
addInitLock(toVariablePayload({ ...query, uuid: dispatchedActions[4].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[5]).toEqual(
|
||||
addInitLock(toVariablePayload({ ...constant, uuid: dispatchedActions[5].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[6]).toEqual(
|
||||
addInitLock(toVariablePayload({ ...custom, uuid: dispatchedActions[6].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[7]).toEqual(
|
||||
addInitLock(toVariablePayload({ ...textbox, uuid: dispatchedActions[7].payload.uuid }))
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when processVariables is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||
const query = variableMockBuilder('query').create();
|
||||
const constant = variableMockBuilder('constant').create();
|
||||
const datasource = variableMockBuilder('datasource').create();
|
||||
const custom = variableMockBuilder('custom').create();
|
||||
const textbox = variableMockBuilder('textbox').create();
|
||||
const list = [query, constant, datasource, custom, textbox];
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({
|
||||
preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query: {} } },
|
||||
})
|
||||
.givenRootReducer(getTemplatingAndLocationRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariables(), true);
|
||||
|
||||
await tester.thenDispatchedActionPredicateShouldEqual(dispatchedActions => {
|
||||
expect(dispatchedActions.length).toEqual(8);
|
||||
|
||||
expect(dispatchedActions[0]).toEqual(
|
||||
resolveInitLock(toVariablePayload({ ...query, uuid: dispatchedActions[0].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[1]).toEqual(
|
||||
resolveInitLock(toVariablePayload({ ...constant, uuid: dispatchedActions[1].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[2]).toEqual(
|
||||
resolveInitLock(toVariablePayload({ ...custom, uuid: dispatchedActions[2].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[3]).toEqual(
|
||||
resolveInitLock(toVariablePayload({ ...textbox, uuid: dispatchedActions[3].payload.uuid }))
|
||||
);
|
||||
|
||||
expect(dispatchedActions[4]).toEqual(
|
||||
removeInitLock(toVariablePayload({ ...query, uuid: dispatchedActions[4].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[5]).toEqual(
|
||||
removeInitLock(toVariablePayload({ ...constant, uuid: dispatchedActions[5].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[6]).toEqual(
|
||||
removeInitLock(toVariablePayload({ ...custom, uuid: dispatchedActions[6].payload.uuid }))
|
||||
);
|
||||
expect(dispatchedActions[7]).toEqual(
|
||||
removeInitLock(toVariablePayload({ ...textbox, uuid: dispatchedActions[7].payload.uuid }))
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setOptionFromUrl is dispatched with a custom variable (no refresh property)', () => {
|
||||
it.each`
|
||||
urlValue | expected
|
||||
${'B'} | ${['B']}
|
||||
${['B']} | ${['B']}
|
||||
${'X'} | ${['X']}
|
||||
${''} | ${['']}
|
||||
${['A', 'B']} | ${['A', 'B']}
|
||||
${null} | ${[null]}
|
||||
${undefined} | ${[undefined]}
|
||||
`('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected }) => {
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
const custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withOptions('A', 'B', 'C')
|
||||
.withCurrent('A')
|
||||
.create();
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom })))
|
||||
.whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'custom', uuid: '0' },
|
||||
{ option: { text: expected, value: expected, selected: false } }
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when validateVariableSelectionState is dispatched with a custom variable (no dependencies)', () => {
|
||||
describe('and not multivalue', () => {
|
||||
it.each`
|
||||
withOptions | withCurrent | defaultValue | expected
|
||||
${['A', 'B', 'C']} | ${undefined} | ${undefined} | ${'A'}
|
||||
${['A', 'B', 'C']} | ${'B'} | ${undefined} | ${'B'}
|
||||
${['A', 'B', 'C']} | ${'B'} | ${'C'} | ${'B'}
|
||||
${['A', 'B', 'C']} | ${'X'} | ${undefined} | ${'A'}
|
||||
${['A', 'B', 'C']} | ${'X'} | ${'C'} | ${'C'}
|
||||
${undefined} | ${'B'} | ${undefined} | ${'A'}
|
||||
`('then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expected }) => {
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
let custom;
|
||||
|
||||
if (!withOptions) {
|
||||
custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withCurrent(withCurrent)
|
||||
.create();
|
||||
custom.options = undefined;
|
||||
}
|
||||
|
||||
if (withOptions) {
|
||||
custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withOptions(...withOptions)
|
||||
.withCurrent(withCurrent)
|
||||
.create();
|
||||
}
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom })))
|
||||
.whenAsyncActionIsDispatched(
|
||||
validateVariableSelectionState(toVariableIdentifier(custom), defaultValue),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionPredicateShouldEqual(dispatchedActions => {
|
||||
const expectedActions: AnyAction[] = !withOptions
|
||||
? []
|
||||
: [
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'custom', uuid: '0' },
|
||||
{ option: { text: expected, value: expected, selected: false } }
|
||||
)
|
||||
),
|
||||
];
|
||||
expect(dispatchedActions).toEqual(expectedActions);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and multivalue', () => {
|
||||
it.each`
|
||||
withOptions | withCurrent | defaultValue | expectedText | expectedSelected
|
||||
${['A', 'B', 'C']} | ${['B']} | ${undefined} | ${['B']} | ${true}
|
||||
${['A', 'B', 'C']} | ${['B']} | ${'C'} | ${['B']} | ${true}
|
||||
${['A', 'B', 'C']} | ${['B', 'C']} | ${undefined} | ${['B', 'C']} | ${true}
|
||||
${['A', 'B', 'C']} | ${['B', 'C']} | ${'C'} | ${['B', 'C']} | ${true}
|
||||
${['A', 'B', 'C']} | ${['X']} | ${undefined} | ${'A'} | ${false}
|
||||
${['A', 'B', 'C']} | ${['X']} | ${'C'} | ${'A'} | ${false}
|
||||
`(
|
||||
'then correct actions are dispatched',
|
||||
async ({ withOptions, withCurrent, defaultValue, expectedText, expectedSelected }) => {
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
let custom;
|
||||
|
||||
if (!withOptions) {
|
||||
custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withMulti()
|
||||
.withCurrent(withCurrent)
|
||||
.create();
|
||||
custom.options = undefined;
|
||||
}
|
||||
|
||||
if (withOptions) {
|
||||
custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withMulti()
|
||||
.withOptions(...withOptions)
|
||||
.withCurrent(withCurrent)
|
||||
.create();
|
||||
}
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom })))
|
||||
.whenAsyncActionIsDispatched(
|
||||
validateVariableSelectionState(toVariableIdentifier(custom), defaultValue),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionPredicateShouldEqual(dispatchedActions => {
|
||||
const expectedActions: AnyAction[] = !withOptions
|
||||
? []
|
||||
: [
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'custom', uuid: '0' },
|
||||
{ option: { text: expectedText, value: expectedText, selected: expectedSelected } }
|
||||
)
|
||||
),
|
||||
];
|
||||
expect(dispatchedActions).toEqual(expectedActions);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
340
public/app/features/templating/state/actions.ts
Normal file
340
public/app/features/templating/state/actions.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import castArray from 'lodash/castArray';
|
||||
import { UrlQueryMap, UrlQueryValue } from '@grafana/runtime';
|
||||
|
||||
import { QueryVariableModel, VariableModel, VariableOption, VariableRefresh, VariableWithOptions } from '../variable';
|
||||
import { StoreState, ThunkResult } from '../../../types';
|
||||
import { getVariable, getVariables } from './selectors';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { Graph } from '../../../core/utils/dag';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { addInitLock, addVariable, removeInitLock, resolveInitLock, setCurrentVariableValue } from './sharedReducer';
|
||||
import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types';
|
||||
|
||||
// process flow queryVariable
|
||||
// thunk => processVariables
|
||||
// adapter => setValueFromUrl
|
||||
// thunk => setOptionFromUrl
|
||||
// adapter => updateOptions
|
||||
// thunk => updateQueryVariableOptions
|
||||
// action => updateVariableOptions
|
||||
// action => updateVariableTags
|
||||
// thunk => validateVariableSelectionState
|
||||
// adapter => setValue
|
||||
// thunk => setOptionAsCurrent
|
||||
// action => setCurrentVariableValue
|
||||
// thunk => variableUpdated
|
||||
// adapter => updateOptions for dependent nodes
|
||||
// adapter => setValue
|
||||
// thunk => setOptionAsCurrent
|
||||
// action => setCurrentVariableValue
|
||||
// thunk => variableUpdated
|
||||
// adapter => updateOptions for dependent nodes
|
||||
// adapter => updateOptions
|
||||
// thunk => updateQueryVariableOptions
|
||||
// action => updateVariableOptions
|
||||
// action => updateVariableTags
|
||||
// thunk => validateVariableSelectionState
|
||||
// adapter => setValue
|
||||
// thunk => setOptionAsCurrent
|
||||
// action => setCurrentVariableValue
|
||||
// thunk => variableUpdated
|
||||
// adapter => updateOptions for dependent nodes
|
||||
|
||||
export const initDashboardTemplating = (list: VariableModel[]): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
let orderIndex = 0;
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const model = list[index];
|
||||
if (!variableAdapters.contains(model.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dispatch(addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model })));
|
||||
}
|
||||
|
||||
for (let index = 0; index < getVariables(getState()).length; index++) {
|
||||
dispatch(addInitLock(toVariablePayload(getVariables(getState())[index])));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => {
|
||||
let dependencies: Array<Promise<any>> = [];
|
||||
|
||||
for (const otherVariable of getVariables(state)) {
|
||||
if (variable === otherVariable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (variableAdapters.contains(variable.type)) {
|
||||
if (variableAdapters.get(variable.type).dependsOn(variable, otherVariable)) {
|
||||
dependencies.push(otherVariable.initLock!.promise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(dependencies);
|
||||
};
|
||||
|
||||
export const processVariable = (identifier: VariableIdentifier, queryParams: UrlQueryMap): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variable = getVariable(identifier.uuid!, getState());
|
||||
await processVariableDependencies(variable, getState());
|
||||
|
||||
const urlValue = queryParams['var-' + variable.name];
|
||||
if (urlValue !== void 0) {
|
||||
await variableAdapters.get(variable.type).setValueFromUrl(variable, urlValue ?? '');
|
||||
await dispatch(resolveInitLock(toVariablePayload(variable)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (variable.hasOwnProperty('refresh')) {
|
||||
const refreshableVariable = variable as QueryVariableModel;
|
||||
if (
|
||||
refreshableVariable.refresh === VariableRefresh.onDashboardLoad ||
|
||||
refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged
|
||||
) {
|
||||
await variableAdapters.get(variable.type).updateOptions(refreshableVariable);
|
||||
await dispatch(resolveInitLock(toVariablePayload(variable)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await dispatch(resolveInitLock(toVariablePayload(variable)));
|
||||
};
|
||||
};
|
||||
|
||||
export const processVariables = (): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const queryParams = getState().location.query;
|
||||
const promises = getVariables(getState()).map(
|
||||
async (variable: VariableModel) => await dispatch(processVariable(toVariableIdentifier(variable), queryParams))
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
for (let index = 0; index < getVariables(getState()).length; index++) {
|
||||
await dispatch(removeInitLock(toVariablePayload(getVariables(getState())[index])));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const setOptionFromUrl = (identifier: VariableIdentifier, urlValue: UrlQueryValue): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variable = getVariable(identifier.uuid!, getState());
|
||||
if (variable.hasOwnProperty('refresh') && (variable as QueryVariableModel).refresh !== VariableRefresh.never) {
|
||||
// updates options
|
||||
await variableAdapters.get(variable.type).updateOptions(variable);
|
||||
}
|
||||
|
||||
// get variable from state
|
||||
const variableFromState = getVariable<VariableWithOptions>(variable.uuid!, getState());
|
||||
if (!variableFromState) {
|
||||
throw new Error(`Couldn't find variable with name: ${variable.name}`);
|
||||
}
|
||||
// Simple case. Value in url matches existing options text or value.
|
||||
let option = variableFromState.options.find(op => {
|
||||
return op.text === urlValue || op.value === urlValue;
|
||||
});
|
||||
|
||||
if (!option) {
|
||||
let defaultText = urlValue as string | string[];
|
||||
const defaultValue = urlValue as string | string[];
|
||||
|
||||
if (Array.isArray(urlValue)) {
|
||||
// Multiple values in the url. We construct text as a list of texts from all matched options.
|
||||
const urlValueArray = urlValue as string[];
|
||||
defaultText = urlValueArray.reduce((acc: string[], item: string) => {
|
||||
const foundOption = variableFromState.options.find(o => o.value === item);
|
||||
if (!foundOption) {
|
||||
// @ts-ignore according to strict null errors this can never happen
|
||||
// TODO: investigate this further or refactor code
|
||||
return [].concat(acc, [item]);
|
||||
}
|
||||
|
||||
// @ts-ignore according to strict null errors this can never happen
|
||||
// TODO: investigate this further or refactor code
|
||||
return [].concat(acc, [foundOption.text]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// It is possible that we did not match the value to any existing option. In that case the url value will be
|
||||
// used anyway for both text and value.
|
||||
option = { text: defaultText, value: defaultValue, selected: false };
|
||||
}
|
||||
|
||||
if (variableFromState.hasOwnProperty('multi')) {
|
||||
// In case variable is multiple choice, we cast to array to preserve the same behaviour as when selecting
|
||||
// the option directly, which will return even single value in an array.
|
||||
option = { text: castArray(option.text), value: castArray(option.value), selected: false };
|
||||
}
|
||||
|
||||
await variableAdapters.get(variable.type).setValue(variableFromState, option);
|
||||
};
|
||||
};
|
||||
|
||||
export const selectOptionsForCurrentValue = (variable: VariableWithOptions): VariableOption[] => {
|
||||
let i, y, value, option;
|
||||
const selected: VariableOption[] = [];
|
||||
|
||||
for (i = 0; i < variable.options.length; i++) {
|
||||
option = { ...variable.options[i] };
|
||||
option.selected = false;
|
||||
if (Array.isArray(variable.current.value)) {
|
||||
for (y = 0; y < variable.current.value.length; y++) {
|
||||
value = variable.current.value[y];
|
||||
if (option.value === value) {
|
||||
option.selected = true;
|
||||
selected.push(option);
|
||||
}
|
||||
}
|
||||
} else if (option.value === variable.current.value) {
|
||||
option.selected = true;
|
||||
selected.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
};
|
||||
|
||||
export const validateVariableSelectionState = (
|
||||
identifier: VariableIdentifier,
|
||||
defaultValue?: string
|
||||
): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variableInState = getVariable<VariableWithOptions>(identifier.uuid!, getState());
|
||||
const current = variableInState.current || (({} as unknown) as VariableOption);
|
||||
const setValue = variableAdapters.get(variableInState.type).setValue;
|
||||
|
||||
if (Array.isArray(current.value)) {
|
||||
const selected = selectOptionsForCurrentValue(variableInState);
|
||||
|
||||
// if none pick first
|
||||
if (selected.length === 0) {
|
||||
const option = variableInState.options[0];
|
||||
return setValue(variableInState, option);
|
||||
}
|
||||
|
||||
const option: VariableOption = {
|
||||
value: selected.map(v => v.value) as string[],
|
||||
text: selected.map(v => v.text) as string[],
|
||||
selected: true,
|
||||
};
|
||||
return setValue(variableInState, option);
|
||||
}
|
||||
|
||||
let option: VariableOption | undefined | null = null;
|
||||
|
||||
// 1. find the current value
|
||||
option = variableInState.options?.find(v => v.text === current.text);
|
||||
if (option) {
|
||||
return setValue(variableInState, option);
|
||||
}
|
||||
|
||||
// 2. find the default value
|
||||
if (defaultValue) {
|
||||
option = variableInState.options?.find(v => v.text === defaultValue);
|
||||
if (option) {
|
||||
return setValue(variableInState, option);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. use the first value
|
||||
if (variableInState.options) {
|
||||
const option = variableInState.options[0];
|
||||
return setValue(variableInState, option);
|
||||
}
|
||||
|
||||
// 4... give up
|
||||
return Promise.resolve();
|
||||
};
|
||||
};
|
||||
|
||||
export const setOptionAsCurrent = (
|
||||
identifier: VariableIdentifier,
|
||||
current: VariableOption,
|
||||
emitChanges: boolean
|
||||
): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
dispatch(setCurrentVariableValue(toVariablePayload(identifier, { option: current })));
|
||||
return dispatch(variableUpdated(identifier, emitChanges));
|
||||
};
|
||||
};
|
||||
|
||||
const createGraph = (variables: VariableModel[]) => {
|
||||
const g = new Graph();
|
||||
|
||||
variables.forEach(v => {
|
||||
g.createNode(v.name);
|
||||
});
|
||||
|
||||
variables.forEach(v1 => {
|
||||
variables.forEach(v2 => {
|
||||
if (v1 === v2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (variableAdapters.get(v1.type).dependsOn(v1, v2)) {
|
||||
g.link(v1.name, v2.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return g;
|
||||
};
|
||||
|
||||
export const variableUpdated = (identifier: VariableIdentifier, emitChangeEvents: boolean): ThunkResult<void> => {
|
||||
return (dispatch, getState) => {
|
||||
// if there is a variable lock ignore cascading update because we are in a boot up scenario
|
||||
const variable = getVariable(identifier.uuid!, getState());
|
||||
if (variable.initLock) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const variables = getVariables(getState());
|
||||
const g = createGraph(variables);
|
||||
|
||||
const node = g.getNode(variable.name);
|
||||
let promises: Array<Promise<any>> = [];
|
||||
if (node) {
|
||||
promises = node.getOptimizedInputEdges().map(e => {
|
||||
const variable = variables.find(v => v.name === e.inputNode.name);
|
||||
if (!variable) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return variableAdapters.get(variable.type).updateOptions(variable);
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
if (emitChangeEvents) {
|
||||
const dashboard = getState().dashboard.getModel();
|
||||
dashboard?.processRepeats();
|
||||
dispatch(updateLocation({ query: getQueryWithVariables(getState) }));
|
||||
dashboard?.startRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => {
|
||||
const queryParams = getState().location.query;
|
||||
|
||||
const queryParamsNew = Object.keys(queryParams)
|
||||
.filter(key => key.indexOf('var-') === -1)
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = queryParams[key];
|
||||
return obj;
|
||||
}, {} as UrlQueryMap);
|
||||
|
||||
for (const variable of getVariables(getState())) {
|
||||
if (variable.skipUrlSync) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const adapter = variableAdapters.get(variable.type);
|
||||
queryParamsNew['var-' + variable.name] = adapter.getValueForUrl(variable);
|
||||
}
|
||||
|
||||
return queryParamsNew;
|
||||
};
|
||||
142
public/app/features/templating/state/helpers.ts
Normal file
142
public/app/features/templating/state/helpers.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { EMPTY_UUID } from './types';
|
||||
import { VariableHide, VariableModel, VariableRefresh, VariableType } from '../variable';
|
||||
import { variablesReducer, VariablesState } from './variablesReducer';
|
||||
import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer';
|
||||
import { variableEditorReducer } from '../editor/reducer';
|
||||
import { locationReducer } from '../../../core/reducers/location';
|
||||
import { VariableAdapter, variableAdapters } from '../adapters';
|
||||
|
||||
export const getVariableState = (
|
||||
noOfVariables: number,
|
||||
inEditorIndex = -1,
|
||||
includeEmpty = false
|
||||
): Record<string, VariableModel> => {
|
||||
const variables: Record<string, VariableModel> = {};
|
||||
|
||||
for (let index = 0; index < noOfVariables; index++) {
|
||||
variables[index] = {
|
||||
uuid: index.toString(),
|
||||
type: 'query',
|
||||
name: `Name-${index}`,
|
||||
hide: VariableHide.dontHide,
|
||||
index,
|
||||
label: `Label-${index}`,
|
||||
skipUrlSync: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (includeEmpty) {
|
||||
variables[EMPTY_UUID] = {
|
||||
uuid: EMPTY_UUID,
|
||||
type: 'query',
|
||||
name: `Name-${EMPTY_UUID}`,
|
||||
hide: VariableHide.dontHide,
|
||||
index: noOfVariables,
|
||||
label: `Label-${EMPTY_UUID}`,
|
||||
skipUrlSync: false,
|
||||
};
|
||||
}
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
export const getVariableTestContext = <Model extends VariableModel>(
|
||||
adapter: VariableAdapter<Model>,
|
||||
variableOverrides: Partial<Model> = {}
|
||||
) => {
|
||||
const defaultVariable = {
|
||||
...adapter.initialState,
|
||||
uuid: '0',
|
||||
index: 0,
|
||||
name: '0',
|
||||
};
|
||||
|
||||
const initialState: VariablesState = {
|
||||
'0': { ...defaultVariable, ...variableOverrides },
|
||||
};
|
||||
|
||||
return { initialState };
|
||||
};
|
||||
|
||||
export const variableMockBuilder = (type: VariableType) => {
|
||||
const initialState = variableAdapters.contains(type)
|
||||
? cloneDeep(variableAdapters.get(type).initialState)
|
||||
: { name: type, type, label: '', hide: VariableHide.dontHide, skipUrlSync: false };
|
||||
const { uuid, index, global, ...rest } = initialState;
|
||||
const model = { ...rest, name: type };
|
||||
|
||||
const withUuid = (uuid: string) => {
|
||||
model.uuid = uuid;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withName = (name: string) => {
|
||||
model.name = name;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withOptions = (...texts: string[]) => {
|
||||
model.options = [];
|
||||
for (let index = 0; index < texts.length; index++) {
|
||||
model.options.push({ text: texts[index], value: texts[index], selected: false });
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withCurrent = (text: string | string[]) => {
|
||||
model.current = { text, value: text, selected: true };
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withRefresh = (refresh: VariableRefresh) => {
|
||||
model.refresh = refresh;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withQuery = (query: string) => {
|
||||
model.query = query;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const withMulti = () => {
|
||||
model.multi = true;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const create = () => model;
|
||||
|
||||
const instance = {
|
||||
withUuid,
|
||||
withName,
|
||||
withOptions,
|
||||
withCurrent,
|
||||
withRefresh,
|
||||
withQuery,
|
||||
withMulti,
|
||||
create,
|
||||
};
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const getTemplatingRootReducer = () =>
|
||||
combineReducers({
|
||||
templating: combineReducers({
|
||||
optionsPicker: optionsPickerReducer,
|
||||
editor: variableEditorReducer,
|
||||
variables: variablesReducer,
|
||||
}),
|
||||
});
|
||||
|
||||
export const getTemplatingAndLocationRootReducer = () =>
|
||||
combineReducers({
|
||||
templating: combineReducers({
|
||||
optionsPicker: optionsPickerReducer,
|
||||
editor: variableEditorReducer,
|
||||
variables: variablesReducer,
|
||||
}),
|
||||
location: locationReducer,
|
||||
});
|
||||
388
public/app/features/templating/state/processVariable.test.ts
Normal file
388
public/app/features/templating/state/processVariable.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { UrlQueryMap } from '@grafana/runtime';
|
||||
|
||||
import { getTemplatingRootReducer, variableMockBuilder } from './helpers';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createQueryVariableAdapter } from '../query/adapter';
|
||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { TemplatingState } from 'app/features/templating/state/reducers';
|
||||
import { initDashboardTemplating, processVariable } from './actions';
|
||||
import { resolveInitLock, setCurrentVariableValue } from './sharedReducer';
|
||||
import { toVariableIdentifier, toVariablePayload } from './types';
|
||||
import { VariableRefresh } from '../variable';
|
||||
import { updateVariableOptions } from '../query/reducer';
|
||||
|
||||
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
||||
getTimeSrv: jest.fn().mockReturnValue({
|
||||
timeRange: jest.fn().mockReturnValue({
|
||||
from: '2001-01-01T01:00:00.000Z',
|
||||
to: '2001-01-01T02:00:00.000Z',
|
||||
raw: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||
getDatasourceSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
metricFindQuery: jest.fn().mockImplementation((query, options) => {
|
||||
if (query === '$custom.*') {
|
||||
return Promise.resolve([
|
||||
{ value: 'AA', text: 'AA' },
|
||||
{ value: 'AB', text: 'AB' },
|
||||
{ value: 'AC', text: 'AC' },
|
||||
]);
|
||||
}
|
||||
|
||||
if (query === '$custom.$queryDependsOnCustom.*') {
|
||||
return Promise.resolve([
|
||||
{ value: 'AAA', text: 'AAA' },
|
||||
{ value: 'AAB', text: 'AAB' },
|
||||
{ value: 'AAC', text: 'AAC' },
|
||||
]);
|
||||
}
|
||||
|
||||
if (query === '*') {
|
||||
return Promise.resolve([
|
||||
{ value: 'A', text: 'A' },
|
||||
{ value: 'B', text: 'B' },
|
||||
{ value: 'C', text: 'C' },
|
||||
]);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('processVariable', () => {
|
||||
// these following processVariable tests will test the following base setup
|
||||
// custom doesn't depend on any other variable
|
||||
// queryDependsOnCustom depends on custom
|
||||
// queryNoDepends doesn't depend on any other variable
|
||||
const getAndSetupProcessVariableContext = () => {
|
||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
const custom = variableMockBuilder('custom')
|
||||
.withUuid('0')
|
||||
.withQuery('A,B,C')
|
||||
.withOptions('A', 'B', 'C')
|
||||
.withCurrent('A')
|
||||
.create();
|
||||
|
||||
const queryDependsOnCustom = variableMockBuilder('query')
|
||||
.withUuid('1')
|
||||
.withName('queryDependsOnCustom')
|
||||
.withQuery('$custom.*')
|
||||
.withOptions('AA', 'AB', 'AC')
|
||||
.withCurrent('AA')
|
||||
.create();
|
||||
|
||||
const queryNoDepends = variableMockBuilder('query')
|
||||
.withUuid('2')
|
||||
.withName('queryNoDepends')
|
||||
.withQuery('*')
|
||||
.withOptions('A', 'B', 'C')
|
||||
.withCurrent('A')
|
||||
.create();
|
||||
|
||||
const list = [custom, queryDependsOnCustom, queryNoDepends];
|
||||
|
||||
return {
|
||||
custom,
|
||||
queryDependsOnCustom,
|
||||
queryNoDepends,
|
||||
list,
|
||||
};
|
||||
};
|
||||
|
||||
// testing processVariable for the custom variable from case described above
|
||||
describe('when processVariable is dispatched for a custom variable without dependencies', () => {
|
||||
describe('and queryParams does not match variable', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom } = getAndSetupProcessVariableContext();
|
||||
const queryParams: UrlQueryMap = {};
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(resolveInitLock(toVariablePayload({ type: 'custom', uuid: '0' })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('and queryParams does match variable', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom } = getAndSetupProcessVariableContext();
|
||||
const queryParams: UrlQueryMap = { 'var-custom': 'B' };
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload({ type: 'custom', uuid: '0' }, { option: { text: ['B'], value: ['B'], selected: false } })
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'custom', uuid: '0' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// testing processVariable for the queryNoDepends variable from case described above
|
||||
describe('when processVariable is dispatched for a query variable without dependencies', () => {
|
||||
describe('and queryParams does not match variable', () => {
|
||||
const queryParams: UrlQueryMap = {};
|
||||
|
||||
describe('and refresh is VariableRefresh.never', () => {
|
||||
const refresh = VariableRefresh.never;
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, queryNoDepends } = getAndSetupProcessVariableContext();
|
||||
queryNoDepends.refresh = refresh;
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '2' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => {
|
||||
describe(`and refresh is ${refresh}`, () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, queryNoDepends } = getAndSetupProcessVariableContext();
|
||||
queryNoDepends.refresh = refresh;
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
updateVariableOptions(
|
||||
toVariablePayload({ type: 'query', uuid: '2' }, [
|
||||
{ value: 'A', text: 'A' },
|
||||
{ value: 'B', text: 'B' },
|
||||
{ value: 'C', text: 'C' },
|
||||
])
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload({ type: 'query', uuid: '2' }, { option: { text: 'A', value: 'A', selected: false } })
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '2' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and queryParams does match variable', () => {
|
||||
const queryParams: UrlQueryMap = { 'var-queryNoDepends': 'B' };
|
||||
|
||||
describe('and refresh is VariableRefresh.never', () => {
|
||||
const refresh = VariableRefresh.never;
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, queryNoDepends } = getAndSetupProcessVariableContext();
|
||||
queryNoDepends.refresh = refresh;
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '2' },
|
||||
{ option: { text: ['B'], value: ['B'], selected: false } }
|
||||
)
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '2' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => {
|
||||
describe(`and refresh is ${
|
||||
refresh === VariableRefresh.onDashboardLoad
|
||||
? 'VariableRefresh.onDashboardLoad'
|
||||
: 'VariableRefresh.onTimeRangeChanged'
|
||||
}`, () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, queryNoDepends } = getAndSetupProcessVariableContext();
|
||||
queryNoDepends.refresh = refresh;
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
updateVariableOptions(
|
||||
toVariablePayload({ type: 'query', uuid: '2' }, [
|
||||
{ value: 'A', text: 'A' },
|
||||
{ value: 'B', text: 'B' },
|
||||
{ value: 'C', text: 'C' },
|
||||
])
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload({ type: 'query', uuid: '2' }, { option: { text: 'A', value: 'A', selected: false } })
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '2' },
|
||||
{ option: { text: ['B'], value: ['B'], selected: false } }
|
||||
)
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '2' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// testing processVariable for the queryDependsOnCustom variable from case described above
|
||||
describe('when processVariable is dispatched for a query variable with one dependency', () => {
|
||||
describe('and queryParams does not match variable', () => {
|
||||
const queryParams: UrlQueryMap = {};
|
||||
|
||||
describe('and refresh is VariableRefresh.never', () => {
|
||||
const refresh = VariableRefresh.never;
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
|
||||
queryDependsOnCustom.refresh = refresh;
|
||||
const customProcessed = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
|
||||
|
||||
const tester = await customProcessed.whenAsyncActionIsDispatched(
|
||||
processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '1' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => {
|
||||
describe(`and refresh is ${refresh}`, () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
|
||||
queryDependsOnCustom.refresh = refresh;
|
||||
const customProcessed = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
|
||||
|
||||
const tester = await customProcessed.whenAsyncActionIsDispatched(
|
||||
processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
updateVariableOptions(
|
||||
toVariablePayload({ type: 'query', uuid: '1' }, [
|
||||
{ value: 'AA', text: 'AA' },
|
||||
{ value: 'AB', text: 'AB' },
|
||||
{ value: 'AC', text: 'AC' },
|
||||
])
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '1' },
|
||||
{ option: { text: 'AA', value: 'AA', selected: false } }
|
||||
)
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '1' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and queryParams does match variable', () => {
|
||||
const queryParams: UrlQueryMap = { 'var-queryDependsOnCustom': 'AB' };
|
||||
|
||||
describe('and refresh is VariableRefresh.never', () => {
|
||||
const refresh = VariableRefresh.never;
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
|
||||
queryDependsOnCustom.refresh = refresh;
|
||||
const customProcessed = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
|
||||
|
||||
const tester = await customProcessed.whenAsyncActionIsDispatched(
|
||||
processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '1' },
|
||||
{ option: { text: ['AB'], value: ['AB'], selected: false } }
|
||||
)
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '1' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
[VariableRefresh.onDashboardLoad, VariableRefresh.onTimeRangeChanged].forEach(refresh => {
|
||||
describe(`and refresh is ${
|
||||
refresh === VariableRefresh.onDashboardLoad
|
||||
? 'VariableRefresh.onDashboardLoad'
|
||||
: 'VariableRefresh.onTimeRangeChanged'
|
||||
}`, () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext();
|
||||
queryDependsOnCustom.refresh = refresh;
|
||||
const customProcessed = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating(list))
|
||||
.whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain
|
||||
|
||||
const tester = await customProcessed.whenAsyncActionIsDispatched(
|
||||
processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams),
|
||||
true
|
||||
);
|
||||
|
||||
await tester.thenDispatchedActionShouldEqual(
|
||||
updateVariableOptions(
|
||||
toVariablePayload({ type: 'query', uuid: '1' }, [
|
||||
{ value: 'AA', text: 'AA' },
|
||||
{ value: 'AB', text: 'AB' },
|
||||
{ value: 'AC', text: 'AC' },
|
||||
])
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '1' },
|
||||
{ option: { text: 'AA', value: 'AA', selected: false } }
|
||||
)
|
||||
),
|
||||
setCurrentVariableValue(
|
||||
toVariablePayload(
|
||||
{ type: 'query', uuid: '1' },
|
||||
{ option: { text: ['AB'], value: ['AB'], selected: false } }
|
||||
)
|
||||
),
|
||||
resolveInitLock(toVariablePayload({ type: 'query', uuid: '1' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
195
public/app/features/templating/state/reducers.test.ts
Normal file
195
public/app/features/templating/state/reducers.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import { cleanUpDashboard } from 'app/features/dashboard/state/reducers';
|
||||
import { VariableHide, VariableModel } from '../variable';
|
||||
import { VariableAdapter, variableAdapters } from '../adapters';
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { variablesReducer, VariablesState } from './variablesReducer';
|
||||
import { toVariablePayload, VariablePayload } from './types';
|
||||
|
||||
describe('variablesReducer', () => {
|
||||
describe('when cleanUpDashboard is dispatched', () => {
|
||||
it('then all variables except global variables should be removed', () => {
|
||||
const initialState: VariablesState = {
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'1': {
|
||||
uuid: '1',
|
||||
type: 'query',
|
||||
name: 'Name-1',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-1',
|
||||
skipUrlSync: false,
|
||||
global: true,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 2,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'3': {
|
||||
uuid: '3',
|
||||
type: 'query',
|
||||
name: 'Name-3',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 3,
|
||||
label: 'Label-3',
|
||||
skipUrlSync: false,
|
||||
global: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(variablesReducer, initialState)
|
||||
.whenActionIsDispatched(cleanUpDashboard())
|
||||
.thenStateShouldEqual({
|
||||
'1': {
|
||||
uuid: '1',
|
||||
type: 'query',
|
||||
name: 'Name-1',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-1',
|
||||
skipUrlSync: false,
|
||||
global: true,
|
||||
},
|
||||
'3': {
|
||||
uuid: '3',
|
||||
type: 'query',
|
||||
name: 'Name-3',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 3,
|
||||
label: 'Label-3',
|
||||
skipUrlSync: false,
|
||||
global: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when any action is dispatched with a type prop that is registered in variableAdapters', () => {
|
||||
it('then the reducer for that variableAdapter should be invoked', () => {
|
||||
const initialState: VariablesState = {
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
};
|
||||
const variableAdapter: VariableAdapter<VariableModel> = {
|
||||
label: 'Mock label',
|
||||
description: 'Mock description',
|
||||
dependsOn: jest.fn(),
|
||||
updateOptions: jest.fn(),
|
||||
initialState: {} as VariableModel,
|
||||
reducer: jest.fn().mockReturnValue(initialState),
|
||||
getValueForUrl: jest.fn(),
|
||||
getSaveModel: jest.fn(),
|
||||
picker: null as any,
|
||||
editor: null as any,
|
||||
setValue: jest.fn(),
|
||||
setValueFromUrl: jest.fn(),
|
||||
};
|
||||
variableAdapters.set('query', variableAdapter);
|
||||
const mockAction = createAction<VariablePayload>('mockAction');
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(variablesReducer, initialState)
|
||||
.whenActionIsDispatched(mockAction(toVariablePayload({ type: 'query', uuid: '0' })))
|
||||
.thenStateShouldEqual(initialState);
|
||||
expect(variableAdapter.reducer).toHaveBeenCalledTimes(1);
|
||||
expect(variableAdapter.reducer).toHaveBeenCalledWith(
|
||||
initialState,
|
||||
mockAction(toVariablePayload({ type: 'query', uuid: '0' }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when any action is dispatched with a type prop that is not registered in variableAdapters', () => {
|
||||
it('then the reducer for that variableAdapter should be invoked', () => {
|
||||
const initialState: VariablesState = {
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
};
|
||||
const variableAdapter: VariableAdapter<VariableModel> = {
|
||||
label: 'Mock label',
|
||||
description: 'Mock description',
|
||||
dependsOn: jest.fn(),
|
||||
updateOptions: jest.fn(),
|
||||
initialState: {} as VariableModel,
|
||||
reducer: jest.fn().mockReturnValue(initialState),
|
||||
getValueForUrl: jest.fn(),
|
||||
getSaveModel: jest.fn(),
|
||||
picker: null as any,
|
||||
editor: null as any,
|
||||
setValue: jest.fn(),
|
||||
setValueFromUrl: jest.fn(),
|
||||
};
|
||||
variableAdapters.set('query', variableAdapter);
|
||||
const mockAction = createAction<VariablePayload>('mockAction');
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(variablesReducer, initialState)
|
||||
.whenActionIsDispatched(mockAction(toVariablePayload({ type: 'adhoc', uuid: '0' })))
|
||||
.thenStateShouldEqual(initialState);
|
||||
expect(variableAdapter.reducer).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when any action is dispatched missing type prop', () => {
|
||||
it('then the reducer for that variableAdapter should be invoked', () => {
|
||||
const initialState: VariablesState = {
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
};
|
||||
const variableAdapter: VariableAdapter<VariableModel> = {
|
||||
label: 'Mock label',
|
||||
description: 'Mock description',
|
||||
dependsOn: jest.fn(),
|
||||
updateOptions: jest.fn(),
|
||||
initialState: {} as VariableModel,
|
||||
reducer: jest.fn().mockReturnValue(initialState),
|
||||
getValueForUrl: jest.fn(),
|
||||
getSaveModel: jest.fn(),
|
||||
picker: null as any,
|
||||
editor: null as any,
|
||||
setValue: jest.fn(),
|
||||
setValueFromUrl: jest.fn(),
|
||||
};
|
||||
variableAdapters.set('query', variableAdapter);
|
||||
const mockAction = createAction<string>('mockAction');
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(variablesReducer, initialState)
|
||||
.whenActionIsDispatched(mockAction('mocked'))
|
||||
.thenStateShouldEqual(initialState);
|
||||
expect(variableAdapter.reducer).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
public/app/features/templating/state/reducers.ts
Normal file
19
public/app/features/templating/state/reducers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import { optionsPickerReducer, OptionsPickerState } from '../pickers/OptionsPicker/reducer';
|
||||
import { variableEditorReducer, VariableEditorState } from '../editor/reducer';
|
||||
import { variablesReducer } from './variablesReducer';
|
||||
import { VariableModel } from '../variable';
|
||||
|
||||
export interface TemplatingState {
|
||||
variables: Record<string, VariableModel>;
|
||||
optionsPicker: OptionsPickerState;
|
||||
editor: VariableEditorState;
|
||||
}
|
||||
|
||||
export default {
|
||||
templating: combineReducers({
|
||||
optionsPicker: optionsPickerReducer,
|
||||
editor: variableEditorReducer,
|
||||
variables: variablesReducer,
|
||||
}),
|
||||
};
|
||||
32
public/app/features/templating/state/selectors.ts
Normal file
32
public/app/features/templating/state/selectors.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { StoreState } from '../../../types';
|
||||
import { VariableModel } from '../variable';
|
||||
import { getState } from '../../../store/store';
|
||||
import { EMPTY_UUID } from './types';
|
||||
|
||||
export const getVariable = <T extends VariableModel = VariableModel>(
|
||||
uuid: string,
|
||||
state: StoreState = getState()
|
||||
): T => {
|
||||
if (!state.templating.variables[uuid]) {
|
||||
throw new Error(`Couldn't find variable with uuid:${uuid}`);
|
||||
}
|
||||
|
||||
return state.templating.variables[uuid] as T;
|
||||
};
|
||||
|
||||
export const getVariableWithName = (name: string, state: StoreState = getState()) => {
|
||||
return Object.values(state.templating.variables).find(variable => variable.name === name);
|
||||
};
|
||||
|
||||
export const getVariables = (state: StoreState = getState()): VariableModel[] => {
|
||||
return Object.values(state.templating.variables).filter(variable => variable.uuid! !== EMPTY_UUID);
|
||||
};
|
||||
|
||||
export const getVariableClones = (state: StoreState = getState(), includeEmptyUuid = false): VariableModel[] => {
|
||||
const variables = Object.values(state.templating.variables)
|
||||
.filter(variable => (includeEmptyUuid ? true : variable.uuid !== EMPTY_UUID))
|
||||
.map(variable => cloneDeep(variable));
|
||||
return variables.sort((s1, s2) => s1.index! - s2.index!);
|
||||
};
|
||||
435
public/app/features/templating/state/sharedReducer.test.ts
Normal file
435
public/app/features/templating/state/sharedReducer.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import {
|
||||
addInitLock,
|
||||
addVariable,
|
||||
changeVariableOrder,
|
||||
changeVariableProp,
|
||||
duplicateVariable,
|
||||
removeInitLock,
|
||||
removeVariable,
|
||||
resolveInitLock,
|
||||
setCurrentVariableValue,
|
||||
sharedReducer,
|
||||
storeNewVariable,
|
||||
} from './sharedReducer';
|
||||
import { QueryVariableModel, VariableHide } from '../variable';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, EMPTY_UUID, toVariablePayload } from './types';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createQueryVariableAdapter } from '../query/adapter';
|
||||
import { initialQueryVariableModelState } from '../query/reducer';
|
||||
import { Deferred } from '../../../core/utils/deferred';
|
||||
import { getVariableState, getVariableTestContext } from './helpers';
|
||||
import { initialVariablesState, VariablesState } from './variablesReducer';
|
||||
|
||||
describe('sharedReducer', () => {
|
||||
describe('when addVariable is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const model = ({ name: 'name from model', type: 'type from model' } as unknown) as QueryVariableModel;
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, { global: true, index: 0, model });
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, { ...initialVariablesState })
|
||||
.whenActionIsDispatched(addVariable(payload))
|
||||
.thenStateShouldEqual({
|
||||
[0]: {
|
||||
...initialQueryVariableModelState,
|
||||
...model,
|
||||
uuid: '0',
|
||||
global: true,
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when removeVariable is dispatched and reIndex is true', () => {
|
||||
it('then state should be correct', () => {
|
||||
const initialState: VariablesState = getVariableState(3);
|
||||
const payload = toVariablePayload({ uuid: '1', type: 'query' }, { reIndex: true });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, initialState)
|
||||
.whenActionIsDispatched(removeVariable(payload))
|
||||
.thenStateShouldEqual({
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when removeVariable is dispatched and reIndex is false', () => {
|
||||
it('then state should be correct', () => {
|
||||
const initialState: VariablesState = getVariableState(3);
|
||||
const payload = toVariablePayload({ uuid: '1', type: 'query' }, { reIndex: false });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, initialState)
|
||||
.whenActionIsDispatched(removeVariable(payload))
|
||||
.thenStateShouldEqual({
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 2,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicateVariable is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
const initialState: VariablesState = getVariableState(3);
|
||||
const payload = toVariablePayload({ uuid: '1', type: 'query' }, { newUuid: '11' });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, initialState)
|
||||
.whenActionIsDispatched(duplicateVariable(payload))
|
||||
.thenStateShouldEqual({
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'1': {
|
||||
uuid: '1',
|
||||
type: 'query',
|
||||
name: 'Name-1',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-1',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 2,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'11': {
|
||||
...initialQueryVariableModelState,
|
||||
uuid: '11',
|
||||
name: 'copy_of_Name-1',
|
||||
index: 3,
|
||||
label: 'Label-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableOrder is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const initialState: VariablesState = getVariableState(3);
|
||||
const payload = toVariablePayload({ uuid: '1', type: 'query' }, { fromIndex: 1, toIndex: 0 });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, initialState)
|
||||
.whenActionIsDispatched(changeVariableOrder(payload))
|
||||
.thenStateShouldEqual({
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'1': {
|
||||
uuid: '1',
|
||||
type: 'query',
|
||||
name: 'Name-1',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-1',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 2,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when storeNewVariable is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
variableAdapters.set('query', createQueryVariableAdapter());
|
||||
const initialState: VariablesState = getVariableState(3, -1, true);
|
||||
const payload = toVariablePayload({ uuid: '11', type: 'query' });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, initialState)
|
||||
.whenActionIsDispatched(storeNewVariable(payload))
|
||||
.thenStateShouldEqual({
|
||||
'0': {
|
||||
uuid: '0',
|
||||
type: 'query',
|
||||
name: 'Name-0',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 0,
|
||||
label: 'Label-0',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'1': {
|
||||
uuid: '1',
|
||||
type: 'query',
|
||||
name: 'Name-1',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 1,
|
||||
label: 'Label-1',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
'2': {
|
||||
uuid: '2',
|
||||
type: 'query',
|
||||
name: 'Name-2',
|
||||
hide: VariableHide.dontHide,
|
||||
index: 2,
|
||||
label: 'Label-2',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
[EMPTY_UUID]: {
|
||||
uuid: EMPTY_UUID,
|
||||
type: 'query',
|
||||
name: `Name-${EMPTY_UUID}`,
|
||||
hide: VariableHide.dontHide,
|
||||
index: 3,
|
||||
label: `Label-${EMPTY_UUID}`,
|
||||
skipUrlSync: false,
|
||||
},
|
||||
[11]: {
|
||||
...initialQueryVariableModelState,
|
||||
uuid: '11',
|
||||
name: `Name-${EMPTY_UUID}`,
|
||||
index: 3,
|
||||
label: `Label-${EMPTY_UUID}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setCurrentVariableValue is dispatched and current.text is an Array with values', () => {
|
||||
it('then state should be correct', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
const { initialState } = getVariableTestContext(adapter, {
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
const current = { text: ['A', 'B'], selected: true, value: ['A', 'B'] };
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, { option: current });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(setCurrentVariableValue(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ selected: false, text: 'All', value: '$__all' },
|
||||
{ selected: true, text: 'A', value: 'A' },
|
||||
{ selected: true, text: 'B', value: 'B' },
|
||||
],
|
||||
current: { selected: true, text: 'A + B', value: ['A', 'B'] },
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setCurrentVariableValue is dispatched and current.value is an Array with values except All value', () => {
|
||||
it('then state should be correct', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
const { initialState } = getVariableTestContext(adapter, {
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
const current = { text: 'A + B', selected: true, value: ['A', 'B'] };
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, { option: current });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(setCurrentVariableValue(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ selected: false, text: 'All', value: '$__all' },
|
||||
{ selected: true, text: 'A', value: 'A' },
|
||||
{ selected: true, text: 'B', value: 'B' },
|
||||
],
|
||||
current: { selected: true, text: 'A + B', value: ['A', 'B'] },
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setCurrentVariableValue is dispatched and current.value is an Array with values containing All value', () => {
|
||||
it('then state should be correct', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
const { initialState } = getVariableTestContext(adapter, {
|
||||
options: [
|
||||
{ text: 'All', value: '$__all', selected: false },
|
||||
{ text: 'A', value: 'A', selected: false },
|
||||
{ text: 'B', value: 'B', selected: false },
|
||||
],
|
||||
});
|
||||
const current = { text: ALL_VARIABLE_TEXT, selected: true, value: [ALL_VARIABLE_VALUE] };
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, { option: current });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(setCurrentVariableValue(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': ({
|
||||
...initialState[0],
|
||||
options: [
|
||||
{ selected: true, text: 'All', value: '$__all' },
|
||||
{ selected: false, text: 'A', value: 'A' },
|
||||
{ selected: false, text: 'B', value: 'B' },
|
||||
],
|
||||
current: { selected: true, text: 'All', value: ['$__all'] },
|
||||
} as unknown) as QueryVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when addInitLock is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
const { initialState } = getVariableTestContext(adapter, {});
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(addInitLock(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({ uuid: '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({ uuid: '0', type: 'query' });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(removeInitLock(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': {
|
||||
...initialState[0],
|
||||
initLock: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changeVariableProp is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const adapter = createQueryVariableAdapter();
|
||||
const { initialState } = getVariableTestContext(adapter);
|
||||
const propName = 'name';
|
||||
const propValue = 'Updated name';
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'query' }, { propName, propValue });
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(sharedReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(changeVariableProp(payload))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
'0': {
|
||||
...initialState[0],
|
||||
name: 'Updated name',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
178
public/app/features/templating/state/sharedReducer.ts
Normal file
178
public/app/features/templating/state/sharedReducer.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { VariableModel, VariableOption, VariableType, VariableWithOptions } from '../variable';
|
||||
import { AddVariable, ALL_VARIABLE_VALUE, EMPTY_UUID, getInstanceState, 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';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const sharedReducerSlice = createSlice({
|
||||
name: 'templating/shared',
|
||||
initialState: initialVariablesState,
|
||||
reducers: {
|
||||
addVariable: (state: VariablesState, action: PayloadAction<VariablePayload<AddVariable>>) => {
|
||||
const uuid = action.payload.uuid ?? v4(); // for testing purposes we can call this with an uuid
|
||||
state[uuid] = {
|
||||
...cloneDeep(variableAdapters.get(action.payload.type).initialState),
|
||||
...action.payload.data.model,
|
||||
};
|
||||
state[uuid].uuid = uuid;
|
||||
state[uuid].index = action.payload.data.index;
|
||||
state[uuid].global = action.payload.data.global;
|
||||
},
|
||||
addInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState(state, action.payload.uuid!);
|
||||
instanceState.initLock = new Deferred();
|
||||
},
|
||||
resolveInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState(state, action.payload.uuid!);
|
||||
instanceState.initLock?.resolve();
|
||||
},
|
||||
removeInitLock: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState(state, action.payload.uuid!);
|
||||
instanceState.initLock = null;
|
||||
},
|
||||
removeVariable: (state: VariablesState, action: PayloadAction<VariablePayload<{ reIndex: boolean }>>) => {
|
||||
delete state[action.payload.uuid!];
|
||||
if (!action.payload.data.reIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableStates = Object.values(state);
|
||||
for (let index = 0; index < variableStates.length; index++) {
|
||||
variableStates[index].index = index;
|
||||
}
|
||||
},
|
||||
duplicateVariable: (state: VariablesState, action: PayloadAction<VariablePayload<{ newUuid: string }>>) => {
|
||||
const newUuid = action.payload.data?.newUuid ?? v4();
|
||||
const original = cloneDeep<VariableModel>(state[action.payload.uuid]);
|
||||
const index = Object.keys(state).length;
|
||||
const name = `copy_of_${original.name}`;
|
||||
state[newUuid] = {
|
||||
...cloneDeep(variableAdapters.get(action.payload.type).initialState),
|
||||
...original,
|
||||
uuid: newUuid,
|
||||
name,
|
||||
index,
|
||||
};
|
||||
},
|
||||
changeVariableOrder: (
|
||||
state: VariablesState,
|
||||
action: PayloadAction<VariablePayload<{ fromIndex: number; toIndex: number }>>
|
||||
) => {
|
||||
const variables = Object.values(state).map(s => s);
|
||||
const fromVariable = variables.find(v => v.index === action.payload.data.fromIndex);
|
||||
const toVariable = variables.find(v => v.index === action.payload.data.toIndex);
|
||||
|
||||
if (fromVariable) {
|
||||
state[fromVariable.uuid!].index = action.payload.data.toIndex;
|
||||
}
|
||||
|
||||
if (toVariable) {
|
||||
state[toVariable.uuid!].index = action.payload.data.fromIndex;
|
||||
}
|
||||
},
|
||||
storeNewVariable: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const uuid = action.payload.uuid!;
|
||||
const emptyVariable = cloneDeep<VariableModel>(state[EMPTY_UUID]);
|
||||
state[uuid!] = {
|
||||
...cloneDeep(variableAdapters.get(action.payload.type).initialState),
|
||||
...emptyVariable,
|
||||
uuid,
|
||||
index: emptyVariable.index,
|
||||
};
|
||||
},
|
||||
changeVariableType: (state: VariablesState, action: PayloadAction<VariablePayload<{ newType: VariableType }>>) => {
|
||||
const { uuid } = action.payload;
|
||||
const { label, name, index } = state[uuid!];
|
||||
|
||||
state[uuid!] = {
|
||||
...cloneDeep(variableAdapters.get(action.payload.data.newType).initialState),
|
||||
uuid,
|
||||
label,
|
||||
name,
|
||||
index,
|
||||
};
|
||||
},
|
||||
setCurrentVariableValue: (
|
||||
state: VariablesState,
|
||||
action: PayloadAction<VariablePayload<{ option: VariableOption }>>
|
||||
) => {
|
||||
const instanceState = getInstanceState<VariableWithOptions>(state, action.payload.uuid);
|
||||
const current = { ...action.payload.data.option };
|
||||
|
||||
if (Array.isArray(current.text) && current.text.length > 0) {
|
||||
current.text = current.text.join(' + ');
|
||||
} else if (Array.isArray(current.value) && current.value[0] !== ALL_VARIABLE_VALUE) {
|
||||
current.text = current.value.join(' + ');
|
||||
}
|
||||
|
||||
instanceState.current = current;
|
||||
instanceState.options = instanceState.options.map(option => {
|
||||
let selected = false;
|
||||
if (Array.isArray(current.value)) {
|
||||
for (let index = 0; index < current.value.length; index++) {
|
||||
const value = current.value[index];
|
||||
if (option.value === value) {
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (option.value === current.value) {
|
||||
selected = true;
|
||||
}
|
||||
option.selected = selected;
|
||||
return option;
|
||||
});
|
||||
|
||||
if (hasTags(current) && isQuery(instanceState)) {
|
||||
const selected = current!.tags!.reduce((all: Record<string, boolean>, tag) => {
|
||||
all[tag.text.toString()] = tag.selected;
|
||||
return all;
|
||||
}, {});
|
||||
|
||||
instanceState.tags = instanceState.tags.map(t => {
|
||||
const text = t.text.toString();
|
||||
t.selected = selected[text];
|
||||
return t;
|
||||
});
|
||||
}
|
||||
},
|
||||
changeVariableProp: (
|
||||
state: VariablesState,
|
||||
action: PayloadAction<VariablePayload<{ propName: string; propValue: any }>>
|
||||
) => {
|
||||
const instanceState = getInstanceState(state, action.payload.uuid!);
|
||||
(instanceState as Record<string, any>)[action.payload.data.propName] = action.payload.data.propValue;
|
||||
},
|
||||
},
|
||||
extraReducers: builder =>
|
||||
builder.addCase(changeVariableNameSucceeded, (state, action) => {
|
||||
const instanceState = getInstanceState(state, action.payload.uuid);
|
||||
instanceState.name = action.payload.data;
|
||||
}),
|
||||
});
|
||||
|
||||
export const sharedReducer = sharedReducerSlice.reducer;
|
||||
|
||||
export const {
|
||||
addInitLock,
|
||||
removeVariable,
|
||||
addVariable,
|
||||
changeVariableProp,
|
||||
changeVariableOrder,
|
||||
storeNewVariable,
|
||||
duplicateVariable,
|
||||
setCurrentVariableValue,
|
||||
changeVariableType,
|
||||
removeInitLock,
|
||||
resolveInitLock,
|
||||
} = sharedReducerSlice.actions;
|
||||
|
||||
const hasTags = (option: VariableOption): boolean => {
|
||||
return Array.isArray(option.tags);
|
||||
};
|
||||
43
public/app/features/templating/state/types.ts
Normal file
43
public/app/features/templating/state/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { VariableModel, VariableType } from '../variable';
|
||||
import { VariablesState } from './variablesReducer';
|
||||
|
||||
export const EMPTY_UUID = '00000000-0000-0000-0000-000000000000';
|
||||
export const ALL_VARIABLE_TEXT = 'All';
|
||||
export const ALL_VARIABLE_VALUE = '$__all';
|
||||
export const NONE_VARIABLE_TEXT = 'None';
|
||||
export const NONE_VARIABLE_VALUE = '';
|
||||
|
||||
export const getInstanceState = <Model extends VariableModel = VariableModel>(state: VariablesState, uuid: string) => {
|
||||
return state[uuid] as Model;
|
||||
};
|
||||
|
||||
export interface VariableIdentifier {
|
||||
type: VariableType;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface VariablePayload<T extends any = undefined> extends VariableIdentifier {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface AddVariable<T extends VariableModel = VariableModel> {
|
||||
global: boolean; // part of dashboard or global
|
||||
index: number; // the order in variables list
|
||||
model: T;
|
||||
}
|
||||
|
||||
export const toVariableIdentifier = (variable: VariableModel): VariableIdentifier => {
|
||||
return { type: variable.type, uuid: variable.uuid! };
|
||||
};
|
||||
|
||||
export function toVariablePayload<T extends any = undefined>(
|
||||
identifier: VariableIdentifier,
|
||||
data?: T
|
||||
): VariablePayload<T>;
|
||||
export function toVariablePayload<T extends any = undefined>(model: VariableModel, data?: T): VariablePayload<T>;
|
||||
export function toVariablePayload<T extends any = undefined>(
|
||||
obj: VariableIdentifier | VariableModel,
|
||||
data?: T
|
||||
): VariablePayload<T> {
|
||||
return { type: obj.type, uuid: obj.uuid!, data: data as T };
|
||||
}
|
||||
38
public/app/features/templating/state/variablesReducer.ts
Normal file
38
public/app/features/templating/state/variablesReducer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { cleanUpDashboard } from '../../dashboard/state/reducers';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { sharedReducer } from './sharedReducer';
|
||||
import { VariableModel } from '../variable';
|
||||
import { VariablePayload } from './types';
|
||||
|
||||
export interface VariablesState extends Record<string, VariableModel> {}
|
||||
|
||||
export const initialVariablesState: VariablesState = {};
|
||||
|
||||
export const variablesReducer = (
|
||||
state: VariablesState = initialVariablesState,
|
||||
action: PayloadAction<VariablePayload>
|
||||
): VariablesState => {
|
||||
if (cleanUpDashboard.match(action)) {
|
||||
const globalVariables = Object.values(state).filter(v => v.global);
|
||||
if (!globalVariables) {
|
||||
return initialVariablesState;
|
||||
}
|
||||
|
||||
const variables = globalVariables.reduce((allVariables, state) => {
|
||||
allVariables[state.uuid!] = state;
|
||||
return allVariables;
|
||||
}, {} as Record<string, VariableModel>);
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
if (action?.payload?.type && variableAdapters.contains(action?.payload?.type)) {
|
||||
// Now that we know we are dealing with a payload that is addressed for an adapted variable let's reduce state:
|
||||
// Firstly call the sharedTemplatingReducer that handles all shared actions between variable types
|
||||
// Secondly call the specific variable type's reducer
|
||||
return variableAdapters.get(action.payload.type).reducer(sharedReducer(state, action), action);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import _ from 'lodash';
|
||||
import { variableRegex } from 'app/features/templating/variable';
|
||||
import { escapeHtml } from 'app/core/utils/text';
|
||||
import { ScopedVars, TimeRange } from '@grafana/data';
|
||||
import { getVariableWithName } from './state/selectors';
|
||||
import { getState } from '../../store/store';
|
||||
import { getConfig } from 'app/core/config';
|
||||
|
||||
function luceneEscape(value: string) {
|
||||
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
|
||||
@@ -19,7 +22,7 @@ export class TemplateSrv {
|
||||
private index: any = {};
|
||||
private grafanaVariables: any = {};
|
||||
private builtIns: any = {};
|
||||
private timeRange: TimeRange = null;
|
||||
private timeRange?: TimeRange | null = null;
|
||||
private fieldAccessorCache: FieldAccessorCache = {};
|
||||
|
||||
constructor() {
|
||||
@@ -216,7 +219,7 @@ export class TemplateSrv {
|
||||
|
||||
variableExists(expression: string) {
|
||||
const name = this.getVariableName(expression);
|
||||
return name && this.index[name] !== void 0;
|
||||
return name && this.getVariableAtIndex(name) !== void 0;
|
||||
}
|
||||
|
||||
highlightVariablesAsHtml(str: string) {
|
||||
@@ -227,7 +230,7 @@ export class TemplateSrv {
|
||||
str = _.escape(str);
|
||||
this.regex.lastIndex = 0;
|
||||
return str.replace(this.regex, (match, var1, var2, fmt2, var3) => {
|
||||
if (this.index[var1 || var2 || var3] || this.builtIns[var1 || var2 || var3]) {
|
||||
if (this.getVariableAtIndex(var1 || var2 || var3) || this.builtIns[var1 || var2 || var3]) {
|
||||
return '<span class="template-variable">' + match + '</span>';
|
||||
}
|
||||
return match;
|
||||
@@ -276,7 +279,7 @@ export class TemplateSrv {
|
||||
|
||||
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
|
||||
const variableName = var1 || var2 || var3;
|
||||
const variable = this.index[variableName];
|
||||
const variable = this.getVariableAtIndex(variableName);
|
||||
const fmt = fmt2 || fmt3 || format;
|
||||
|
||||
if (scopedVars) {
|
||||
@@ -338,7 +341,7 @@ export class TemplateSrv {
|
||||
}
|
||||
}
|
||||
|
||||
variable = this.index[var1 || var2 || var3];
|
||||
variable = this.getVariableAtIndex(var1 || var2 || var3);
|
||||
if (!variable) {
|
||||
return match;
|
||||
}
|
||||
@@ -375,6 +378,18 @@ export class TemplateSrv {
|
||||
});
|
||||
return value.join(',');
|
||||
}
|
||||
|
||||
private getVariableAtIndex = (name: string): any => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getConfig().featureToggles.newVariables && !this.index[name]) {
|
||||
return getVariableWithName(name, getState());
|
||||
}
|
||||
|
||||
return this.index[name];
|
||||
};
|
||||
}
|
||||
|
||||
export default new TemplateSrv();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { ChangeEvent, PureComponent } from 'react';
|
||||
import { TextBoxVariableModel } from '../variable';
|
||||
import { VariableEditorProps } from '../editor/types';
|
||||
|
||||
export interface Props extends VariableEditorProps<TextBoxVariableModel> {}
|
||||
export class TextBoxVariableEditor extends PureComponent<Props> {
|
||||
onQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.onPropChange({ propName: 'query', propValue: event.target.value, updateOptions: false });
|
||||
};
|
||||
onQueryBlur = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
this.props.onPropChange({ propName: 'query', propValue: event.target.value, updateOptions: true });
|
||||
};
|
||||
render() {
|
||||
const { query } = this.props.variable;
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<h5 className="section-heading">Text options</h5>
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Default value</span>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
value={query}
|
||||
onChange={this.onQueryChange}
|
||||
onBlur={this.onQueryBlur}
|
||||
placeholder="default value, if any"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { ChangeEvent, FocusEvent, KeyboardEvent, PureComponent } from 'react';
|
||||
|
||||
import { TextBoxVariableModel } from '../variable';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { changeVariableProp } from '../state/sharedReducer';
|
||||
import { VariablePickerProps } from '../pickers/types';
|
||||
|
||||
export interface Props extends VariablePickerProps<TextBoxVariableModel> {}
|
||||
|
||||
export class TextBoxVariablePicker extends PureComponent<Props> {
|
||||
onQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(
|
||||
changeVariableProp(toVariablePayload(this.props.variable, { propName: 'query', propValue: event.target.value }))
|
||||
);
|
||||
};
|
||||
|
||||
onQueryBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (this.props.variable.current.value !== this.props.variable.query) {
|
||||
variableAdapters.get(this.props.variable.type).updateOptions(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);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={this.props.variable.query}
|
||||
className="gf-form-input width-12"
|
||||
onChange={this.onQueryChange}
|
||||
onBlur={this.onQueryBlur}
|
||||
onKeyDown={this.onQueryKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
57
public/app/features/templating/textbox/actions.test.ts
Normal file
57
public/app/features/templating/textbox/actions.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createTextBoxVariableAdapter } from './adapter';
|
||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||
import { TemplatingState } from 'app/features/templating/state/reducers';
|
||||
import { updateTextBoxVariableOptions } from './actions';
|
||||
import { getTemplatingRootReducer } from '../state/helpers';
|
||||
import { VariableOption, VariableHide, TextBoxVariableModel } from '../variable';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { createTextBoxOptions } from './reducer';
|
||||
import { setCurrentVariableValue } from '../state/sharedReducer';
|
||||
import { initDashboardTemplating } from '../state/actions';
|
||||
|
||||
describe('textbox actions', () => {
|
||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||
|
||||
describe('when updateTextBoxVariableOptions is dispatched', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const option: VariableOption = {
|
||||
value: 'A',
|
||||
text: 'A',
|
||||
selected: false,
|
||||
};
|
||||
|
||||
const variable: TextBoxVariableModel = {
|
||||
type: 'textbox',
|
||||
uuid: '0',
|
||||
global: false,
|
||||
current: {
|
||||
value: '',
|
||||
text: '',
|
||||
selected: false,
|
||||
},
|
||||
options: [],
|
||||
query: 'A',
|
||||
name: 'textbox',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
skipUrlSync: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||
.givenRootReducer(getTemplatingRootReducer())
|
||||
.whenActionIsDispatched(initDashboardTemplating([variable]))
|
||||
.whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toVariablePayload(variable)), true);
|
||||
|
||||
tester.thenDispatchedActionPredicateShouldEqual(actions => {
|
||||
const [createAction, setCurrentAction] = actions;
|
||||
const expectedNumberOfActions = 2;
|
||||
|
||||
expect(createAction).toEqual(createTextBoxOptions(toVariablePayload(variable)));
|
||||
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
|
||||
return actions.length === expectedNumberOfActions;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
14
public/app/features/templating/textbox/actions.ts
Normal file
14
public/app/features/templating/textbox/actions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { TextBoxVariableModel } from '../variable';
|
||||
import { ThunkResult } from '../../../types';
|
||||
import { getVariable } from '../state/selectors';
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { createTextBoxOptions } from './reducer';
|
||||
import { toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
|
||||
export const updateTextBoxVariableOptions = (identifier: VariableIdentifier): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(createTextBoxOptions(toVariablePayload(identifier)));
|
||||
const variableInState = getVariable<TextBoxVariableModel>(identifier.uuid!, getState());
|
||||
await variableAdapters.get(identifier.type).setValue(variableInState, variableInState.options[0], true);
|
||||
};
|
||||
};
|
||||
41
public/app/features/templating/textbox/adapter.ts
Normal file
41
public/app/features/templating/textbox/adapter.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { TextBoxVariableModel } from '../variable';
|
||||
import { initialTextBoxVariableModelState, textBoxVariableReducer } from './reducer';
|
||||
import { dispatch } from '../../../store/store';
|
||||
import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions';
|
||||
import { VariableAdapter } from '../adapters';
|
||||
import { TextBoxVariablePicker } from './TextBoxVariablePicker';
|
||||
import { TextBoxVariableEditor } from './TextBoxVariableEditor';
|
||||
import { updateTextBoxVariableOptions } from './actions';
|
||||
import { toVariableIdentifier } from '../state/types';
|
||||
|
||||
export const createTextBoxVariableAdapter = (): VariableAdapter<TextBoxVariableModel> => {
|
||||
return {
|
||||
description: 'Define a textbox variable, where users can enter any arbitrary string',
|
||||
label: 'Text box',
|
||||
initialState: initialTextBoxVariableModelState,
|
||||
reducer: textBoxVariableReducer,
|
||||
picker: TextBoxVariablePicker,
|
||||
editor: TextBoxVariableEditor,
|
||||
dependsOn: (variable, variableToTest) => {
|
||||
return false;
|
||||
},
|
||||
setValue: async (variable, option, emitChanges = false) => {
|
||||
await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges));
|
||||
},
|
||||
setValueFromUrl: async (variable, urlValue) => {
|
||||
await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue));
|
||||
},
|
||||
updateOptions: async variable => {
|
||||
await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable)));
|
||||
},
|
||||
getSaveModel: variable => {
|
||||
const { index, uuid, initLock, global, ...rest } = cloneDeep(variable);
|
||||
return rest;
|
||||
},
|
||||
getValueForUrl: variable => {
|
||||
return variable.current.value;
|
||||
},
|
||||
};
|
||||
};
|
||||
72
public/app/features/templating/textbox/reducer.test.ts
Normal file
72
public/app/features/templating/textbox/reducer.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { getVariableTestContext } from '../state/helpers';
|
||||
import { toVariablePayload } from '../state/types';
|
||||
import { textBoxVariableReducer, createTextBoxOptions } from './reducer';
|
||||
import { VariablesState } from '../state/variablesReducer';
|
||||
import { TextBoxVariableModel } from '../variable';
|
||||
import { createTextBoxVariableAdapter } from './adapter';
|
||||
|
||||
describe('textBoxVariableReducer', () => {
|
||||
const adapter = createTextBoxVariableAdapter();
|
||||
|
||||
describe('when createTextBoxOptions is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = 'ABC';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'textbox' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(textBoxVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createTextBoxOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: query,
|
||||
value: query,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
current: {
|
||||
text: query,
|
||||
value: query,
|
||||
selected: false,
|
||||
},
|
||||
} as TextBoxVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when createTextBoxOptions is dispatched and query contains spaces', () => {
|
||||
it('then state should be correct', () => {
|
||||
const query = ' ABC ';
|
||||
const uuid = '0';
|
||||
const { initialState } = getVariableTestContext(adapter, { uuid, query });
|
||||
const payload = toVariablePayload({ uuid: '0', type: 'textbox' });
|
||||
|
||||
reducerTester<VariablesState>()
|
||||
.givenReducer(textBoxVariableReducer, cloneDeep(initialState))
|
||||
.whenActionIsDispatched(createTextBoxOptions(payload))
|
||||
.thenStateShouldEqual({
|
||||
[uuid]: {
|
||||
...initialState[uuid],
|
||||
options: [
|
||||
{
|
||||
text: query.trim(),
|
||||
value: query.trim(),
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
current: {
|
||||
text: query.trim(),
|
||||
value: query.trim(),
|
||||
selected: false,
|
||||
},
|
||||
} as TextBoxVariableModel,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
38
public/app/features/templating/textbox/reducer.ts
Normal file
38
public/app/features/templating/textbox/reducer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { TextBoxVariableModel, VariableHide, VariableOption } from '../variable';
|
||||
import { EMPTY_UUID, getInstanceState, VariablePayload } from '../state/types';
|
||||
import { initialVariablesState, VariablesState } from '../state/variablesReducer';
|
||||
|
||||
export const initialTextBoxVariableModelState: TextBoxVariableModel = {
|
||||
uuid: EMPTY_UUID,
|
||||
global: false,
|
||||
index: -1,
|
||||
type: 'textbox',
|
||||
name: '',
|
||||
label: '',
|
||||
hide: VariableHide.dontHide,
|
||||
query: '',
|
||||
current: {} as VariableOption,
|
||||
options: [],
|
||||
skipUrlSync: false,
|
||||
initLock: null,
|
||||
};
|
||||
|
||||
export const textBoxVariableSlice = createSlice({
|
||||
name: 'templating/textbox',
|
||||
initialState: initialVariablesState,
|
||||
reducers: {
|
||||
createTextBoxOptions: (state: VariablesState, action: PayloadAction<VariablePayload>) => {
|
||||
const instanceState = getInstanceState<TextBoxVariableModel>(state, action.payload.uuid!);
|
||||
instanceState.options = [
|
||||
{ text: instanceState.query.trim(), value: instanceState.query.trim(), selected: false },
|
||||
];
|
||||
instanceState.current = instanceState.options[0];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const textBoxVariableReducer = textBoxVariableSlice.reducer;
|
||||
|
||||
export const { createTextBoxOptions } = textBoxVariableSlice.actions;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user