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:
Marcus Andersson
2020-03-10 08:53:41 +01:00
committed by GitHub
parent 88c3b719a1
commit cc813d71c6
108 changed files with 8302 additions and 236 deletions

View File

@@ -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"
},

View File

@@ -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;

View File

@@ -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, []);
}

View File

@@ -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 />);
});

View File

@@ -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 });

View File

@@ -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 = {};

View File

@@ -8,6 +8,7 @@ jest.mock('app/core/store', () => {
return {
getBool: jest.fn(),
set: jest.fn(),
getObject: jest.fn(),
};
});

View 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);
};

View 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);
}
}

View File

@@ -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(),
}));

View File

@@ -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({

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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)} />;
}
}

View File

@@ -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)} />;
}
}

View File

@@ -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>
);
})}
</>
);
};

View File

@@ -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';

View File

@@ -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>
);
})}
</>
);
};

View File

@@ -1,2 +1,2 @@
export { SubMenuCtrl } from './SubMenuCtrl';
export { SubMenu } from './SubMenu';
export { AngularSubMenu } from './AngularSubMenu';

View File

@@ -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}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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),
};
});
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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: () => {},

View File

@@ -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({

View 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 }));
},
};

View File

@@ -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());

View File

@@ -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>
</>
);
}
}

View 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;
});
});
});
});

View 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));
};
};

View 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;
},
};
};

View 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,
});
});
});
});

View 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;

View File

@@ -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,

View File

@@ -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} />
</>
);
}
}

View 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;
});
});
});
});

View 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));
};
};

View 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;
},
};
};

View 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,
});
});
});
});

View 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;

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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
);

View 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
);

View 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>
);
}
}

View File

@@ -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';

View 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());
};

View 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: [{}],
},
});
});
});
});

View 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;

View 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;
}

View File

@@ -0,0 +1,5 @@
import { VariableModel, QueryVariableModel } from './variable';
export const isQuery = (model: VariableModel): model is QueryVariableModel => {
return model.type === 'query';
};

View File

@@ -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';

View File

@@ -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,
};
}

View 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,
};
}

View File

@@ -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,
});
});
});
});

View 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;

View 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>
);
};

View File

@@ -0,0 +1 @@
export { OptionsPicker } from './OptionsPicker/OptionsPicker';

View File

@@ -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}
/>
);
}
}

View File

@@ -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 }}>
&nbsp;&nbsp;<i className="fa fa-tag"></i>&nbsp; {tag.text}
</span>
</span>
);
})}
<i className="fa fa-caret-down" style={{ fontSize: '12px' }}></i>
</a>
);
}
}

View File

@@ -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}&nbsp;&nbsp;<i className="fa fa-tag"></i>&nbsp;
</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>
);
}
}

View 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,
}

View 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
);

View 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,
};
}

View 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);
};

View 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;
},
};
};

View 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,
});
});
});
});

View 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;

View File

@@ -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 => {

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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: [

View 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;
});
}
);
});
});
});

View 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;
};

View 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,
});

View 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' }))
);
});
});
});
});
});
});

View 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);
});
});
});

View 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,
}),
};

View 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!);
};

View 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',
},
});
});
});
});

View 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);
};

View 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 };
}

View 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;
};

View File

@@ -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();

View File

@@ -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>
);
}
}

View File

@@ -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}
/>
);
}
}

View 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;
});
});
});
});

View 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);
};
};

View 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;
},
};
};

View 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,
});
});
});
});

View 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