diff --git a/package.json b/package.json index 8e99ae44025..df313dcdd09 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 392c1737764..fd033e1aaed 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -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; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 91fcd48041a..a8be257a6c9 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -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, []); } diff --git a/public/app/core/components/DynamicImports/SafeDynamicImport.test.tsx b/public/app/core/components/DynamicImports/SafeDynamicImport.test.tsx index 2399bfff21b..3dbfe7b5f29 100644 --- a/public/app/core/components/DynamicImports/SafeDynamicImport.test.tsx +++ b/public/app/core/components/DynamicImports/SafeDynamicImport.test.tsx @@ -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(); }); diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index 590231296d4..c777f4773fe 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -87,7 +87,7 @@ describe('rootReducer', () => { } as StoreState; reducerTester() - .givenReducer(rootReducer, state, true) + .givenReducer(rootReducer, state, false, true) .whenActionIsDispatched(cleanUpAction({ stateSelector: (storeState: StoreState) => storeState.teams })) .thenStatePredicateShouldEqual(resultingState => { expect(resultingState.teams).toEqual({ ...initialTeamsState }); diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 686d8049ab6..35492dc6d15 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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 = {}; diff --git a/public/app/core/specs/search_srv.test.ts b/public/app/core/specs/search_srv.test.ts index 8290d4147cf..e7dc17bdc56 100644 --- a/public/app/core/specs/search_srv.test.ts +++ b/public/app/core/specs/search_srv.test.ts @@ -8,6 +8,7 @@ jest.mock('app/core/store', () => { return { getBool: jest.fn(), set: jest.fn(), + getObject: jest.fn(), }; }); diff --git a/public/app/core/utils/applyStateChanges.ts b/public/app/core/utils/applyStateChanges.ts new file mode 100644 index 00000000000..f3ce5c2de4a --- /dev/null +++ b/public/app/core/utils/applyStateChanges.ts @@ -0,0 +1,7 @@ +export type MutateStateFunc = (state: S) => S; + +export const applyStateChanges = (state: S, ...args: Array>): S => { + return args.reduce((all, cur) => { + return cur(all); + }, state); +}; diff --git a/public/app/core/utils/deferred.ts b/public/app/core/utils/deferred.ts new file mode 100644 index 00000000000..f74899171dc --- /dev/null +++ b/public/app/core/utils/deferred.ts @@ -0,0 +1,14 @@ +export class Deferred { + resolve: any; + reject: any; + promise: Promise; + constructor() { + this.resolve = null; + this.reject = null; + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + Object.freeze(this); + } +} diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 38b26071ea4..f54f112a788 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -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(), })); diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 2627429e1d3..6790a27387c 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -54,7 +54,7 @@ export class DashboardExporter { const promises: Array> = []; 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({ diff --git a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts index 9b68dd4467c..8fe0aa6d05c 100644 --- a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts +++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts @@ -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); } } diff --git a/public/app/features/dashboard/components/DashboardSettings/template.html b/public/app/features/dashboard/components/DashboardSettings/template.html index 5201d7d8915..7985dbfc50d 100644 --- a/public/app/features/dashboard/components/DashboardSettings/template.html +++ b/public/app/features/dashboard/components/DashboardSettings/template.html @@ -77,10 +77,14 @@ ng-include="'public/app/features/annotations/partials/editor.html'"> -
+
+ +
+
diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts index 7b51bcb4b38..c4b675ecf40 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts @@ -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, diff --git a/public/app/features/dashboard/components/PanelEditor/state/reducers.ts b/public/app/features/dashboard/components/PanelEditor/state/reducers.ts index 72b6987b044..29beaab0995 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/reducers.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/reducers.ts @@ -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) => { state.getPanel = () => action.payload.panel; diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx index 858d92e8ccd..0f99dbe8ce0 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx @@ -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'; diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx index 5a9840a548a..13f22fc7d4c 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -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 { }); // remove template queries - dash.templating.list.forEach(variable => { + dash.getVariables().forEach(variable => { variable.query = ''; variable.options = variable.current; variable.refresh = false; diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/app/features/dashboard/components/SubMenu/AngularDashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/AngularDashboardLinks.tsx new file mode 100644 index 00000000000..82e4159d500 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/AngularDashboardLinks.tsx @@ -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 { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + if (!this.hasLinks()) { + return; + } + + const loader = getAngularLoader(); + const template = ''; + 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
(this.element = element)} />; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/AngularSubMenu.tsx b/public/app/features/dashboard/components/SubMenu/AngularSubMenu.tsx new file mode 100644 index 00000000000..4faca44be2c --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/AngularSubMenu.tsx @@ -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 { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
(this.element = element)} />; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/Annotations.tsx b/public/app/features/dashboard/components/SubMenu/Annotations.tsx new file mode 100644 index 00000000000..c182a2c9574 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/Annotations.tsx @@ -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 = ({ 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 ( +
+ onAnnotationChanged(annotation)} + /> +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 6f2a60f624e..5fa5c35e8ca 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -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 { - element: HTMLElement; - angularCmp: AngularComponent; +interface ConnectedProps { + variables: VariableModel[]; +} - componentDidMount() { - const loader = getAngularLoader(); +interface DispatchProps {} - const template = ''; - 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 { + 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
(this.element = element)} />; + if (!this.isSubMenuVisible()) { + return null; + } + + return ( +
+ + +
+ +
+
+ ); } } + +const mapStateToProps: MapStateToProps = state => ({ + variables: getVariableClones(state, false), +}); + +export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected); +SubMenu.displayName = 'SubMenu'; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx new file mode 100644 index 00000000000..af1c37f2bcc --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx @@ -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 = ({ variables }) => { + const [visibleVariables, setVisibleVariables] = useState([]); + useEffect(() => { + setVisibleVariables(variables.filter(state => state.hide !== VariableHide.hideVariable)); + }, [variables]); + + if (visibleVariables.length === 0) { + return null; + } + + return ( + <> + {visibleVariables.map(variable => { + return ( +
+ +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts index ca113ab75d6..4148083a2e2 100644 --- a/public/app/features/dashboard/components/SubMenu/index.ts +++ b/public/app/features/dashboard/components/SubMenu/index.ts @@ -1,2 +1,2 @@ export { SubMenuCtrl } from './SubMenuCtrl'; -export { SubMenu } from './SubMenu'; +export { AngularSubMenu } from './AngularSubMenu'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index baf27456ecb..2aab5c215b2 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -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 { {initError && this.renderInitFailedState()}
- + {!getConfig().featureToggles.newVariables && } + {getConfig().featureToggles.newVariables && } - - { - 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; diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index 358f9086635..0ac880e122a 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -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; } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 11a84e6c3a8..8471375bdbe 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -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), + }; + }); + } } diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 03e0205c64f..fd89ffaa0ce 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -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('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; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 01f6fc06d24..8a8fed2d09a 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -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 { // 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); diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index 242959f347c..c8dcd3ebece 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -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: () => {}, diff --git a/public/app/features/panel/repeat_option.ts b/public/app/features/panel/repeat_option.ts index addb241bcd5..2fab9e695ad 100644 --- a/public/app/features/panel/repeat_option.ts +++ b/public/app/features/panel/repeat_option.ts @@ -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 = `
+
+
+ + ); + } +} diff --git a/public/app/features/templating/constant/actions.test.ts b/public/app/features/templating/constant/actions.test.ts new file mode 100644 index 00000000000..f509c601a8d --- /dev/null +++ b/public/app/features/templating/constant/actions.test.ts @@ -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; + }); + }); + }); +}); diff --git a/public/app/features/templating/constant/actions.ts b/public/app/features/templating/constant/actions.ts new file mode 100644 index 00000000000..227b7b8e845 --- /dev/null +++ b/public/app/features/templating/constant/actions.ts @@ -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 => { + return async dispatch => { + await dispatch(createConstantOptionsFromQuery(toVariablePayload(identifier))); + await dispatch(validateVariableSelectionState(identifier)); + }; +}; diff --git a/public/app/features/templating/constant/adapter.ts b/public/app/features/templating/constant/adapter.ts new file mode 100644 index 00000000000..03ab39d4e63 --- /dev/null +++ b/public/app/features/templating/constant/adapter.ts @@ -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 => { + 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; + }, + }; +}; diff --git a/public/app/features/templating/constant/reducer.test.ts b/public/app/features/templating/constant/reducer.test.ts new file mode 100644 index 00000000000..f22e1e0799b --- /dev/null +++ b/public/app/features/templating/constant/reducer.test.ts @@ -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() + .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() + .givenReducer(constantVariableReducer, cloneDeep(initialState)) + .whenActionIsDispatched(createConstantOptionsFromQuery(payload)) + .thenStateShouldEqual({ + [uuid]: { + ...initialState[uuid], + options: [ + { + text: query.trim(), + value: query.trim(), + selected: false, + }, + ], + } as ConstantVariableModel, + }); + }); + }); +}); diff --git a/public/app/features/templating/constant/reducer.ts b/public/app/features/templating/constant/reducer.ts new file mode 100644 index 00000000000..d0c4cfbfa67 --- /dev/null +++ b/public/app/features/templating/constant/reducer.ts @@ -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) => { + const instanceState = getInstanceState(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; diff --git a/public/app/features/templating/constant_variable.ts b/public/app/features/templating/constant_variable.ts index 8525b02fc43..8097671e614 100644 --- a/public/app/features/templating/constant_variable.ts +++ b/public/app/features/templating/constant_variable.ts @@ -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, diff --git a/public/app/features/templating/custom/CustomVariableEditor.tsx b/public/app/features/templating/custom/CustomVariableEditor.tsx new file mode 100644 index 00000000000..c89279ab7f4 --- /dev/null +++ b/public/app/features/templating/custom/CustomVariableEditor.tsx @@ -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 {} + +export class CustomVariableEditor extends PureComponent { + onChange = (event: ChangeEvent) => { + this.props.onPropChange({ + propName: 'query', + propValue: event.target.value, + }); + }; + + onSelectionOptionsChange = async ({ propName, propValue }: OnPropChangeArguments) => { + this.props.onPropChange({ propName, propValue, updateOptions: true }); + }; + + onBlur = (event: FocusEvent) => { + this.props.onPropChange({ + propName: 'query', + propValue: event.target.value, + updateOptions: true, + }); + }; + + render() { + return ( + <> +
+
Custom Options
+
+ Values separated by comma + +
+
+ + + ); + } +} diff --git a/public/app/features/templating/custom/actions.test.ts b/public/app/features/templating/custom/actions.test.ts new file mode 100644 index 00000000000..e363a339797 --- /dev/null +++ b/public/app/features/templating/custom/actions.test.ts @@ -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; + }); + }); + }); +}); diff --git a/public/app/features/templating/custom/actions.ts b/public/app/features/templating/custom/actions.ts new file mode 100644 index 00000000000..92a10ae7518 --- /dev/null +++ b/public/app/features/templating/custom/actions.ts @@ -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 => { + return async dispatch => { + await dispatch(createCustomOptionsFromQuery(toVariablePayload(identifier))); + await dispatch(validateVariableSelectionState(identifier)); + }; +}; diff --git a/public/app/features/templating/custom/adapter.ts b/public/app/features/templating/custom/adapter.ts new file mode 100644 index 00000000000..d0ecd968457 --- /dev/null +++ b/public/app/features/templating/custom/adapter.ts @@ -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 => { + 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; + }, + }; +}; diff --git a/public/app/features/templating/custom/reducer.test.ts b/public/app/features/templating/custom/reducer.test.ts new file mode 100644 index 00000000000..eb0302a41b2 --- /dev/null +++ b/public/app/features/templating/custom/reducer.test.ts @@ -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() + .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() + .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() + .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, + }); + }); + }); +}); diff --git a/public/app/features/templating/custom/reducer.ts b/public/app/features/templating/custom/reducer.ts new file mode 100644 index 00000000000..8ae7de6bf91 --- /dev/null +++ b/public/app/features/templating/custom/reducer.ts @@ -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) => { + const instanceState = getInstanceState(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; diff --git a/public/app/features/templating/datasource_variable.ts b/public/app/features/templating/datasource_variable.ts index 859b622820c..835a9bfe22e 100644 --- a/public/app/features/templating/datasource_variable.ts +++ b/public/app/features/templating/datasource_variable.ts @@ -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) { diff --git a/public/app/features/templating/editor/SelectionOptionsEditor.tsx b/public/app/features/templating/editor/SelectionOptionsEditor.tsx new file mode 100644 index 00000000000..9505e320bfc --- /dev/null +++ b/public/app/features/templating/editor/SelectionOptionsEditor.tsx @@ -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 + extends VariableEditorProps {} + +export const SelectionOptionsEditor: FunctionComponent = props => { + const onMultiChanged = useCallback( + (event: React.ChangeEvent) => { + props.onPropChange({ propName: 'multi', propValue: event.target.checked }); + }, + [props.onPropChange] + ); + + const onIncludeAllChanged = useCallback( + (event: React.ChangeEvent) => { + props.onPropChange({ propName: 'includeAll', propValue: event.target.checked }); + }, + [props.onPropChange] + ); + + const onAllValueChanged = useCallback( + (event: React.ChangeEvent) => { + props.onPropChange({ propName: 'allValue', propValue: event.target.value }); + }, + [props.onPropChange] + ); + return ( +
+
Selection Options
+
+
+ +
+
+ +
+
+ {props.variable.includeAll && ( +
+ Custom all value + +
+ )} +
+ ); +}; +SelectionOptionsEditor.displayName = 'SelectionOptionsEditor'; diff --git a/public/app/features/templating/editor/VariableEditorContainer.tsx b/public/app/features/templating/editor/VariableEditorContainer.tsx new file mode 100644 index 00000000000..dcc74f64da8 --- /dev/null +++ b/public/app/features/templating/editor/VariableEditorContainer.tsx @@ -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 { + componentDidMount(): void { + this.props.switchToListMode(); + } + + onChangeToListMode = (event: MouseEvent) => { + event.preventDefault(); + this.props.switchToListMode(); + }; + + onEditVariable = (identifier: VariableIdentifier) => { + this.props.switchToEditMode(identifier); + }; + + onNewVariable = (event: MouseEvent) => { + 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 ( +
+
+

+ + Variables + + {this.props.idInEditor === EMPTY_UUID && ( + + + New + + )} + {this.props.idInEditor && this.props.idInEditor !== EMPTY_UUID && ( + + + Edit + + )} +

+ +
+ {this.props.variables.length > 0 && variableToEdit === null && ( + + New + + )} +
+ + {!variableToEdit && ( + + )} + {variableToEdit && } +
+ ); + } +} + +const mapStateToProps: MapStateToProps = state => ({ + variables: getVariableClones(state, true), + idInEditor: state.templating.editor.id, +}); + +const mapDispatchToProps: MapDispatchToProps = { + changeVariableOrder, + duplicateVariable, + removeVariable, + switchToNewMode, + switchToEditMode, + switchToListMode, +}; + +export const VariableEditorContainer = connectWithStore( + VariableEditorContainerUnconnected, + mapStateToProps, + mapDispatchToProps +); diff --git a/public/app/features/templating/editor/VariableEditorEditor.tsx b/public/app/features/templating/editor/VariableEditorEditor.tsx new file mode 100644 index 00000000000..ad817b24ea8 --- /dev/null +++ b/public/app/features/templating/editor/VariableEditorEditor.tsx @@ -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 { + componentDidMount(): void { + this.props.variableEditorMount(this.props.identifier); + } + + componentDidUpdate(prevProps: Readonly, 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) => { + event.preventDefault(); + this.props.changeVariableName(this.props.identifier, event.target.value); + }; + + onTypeChange = (event: ChangeEvent) => { + event.preventDefault(); + this.props.changeVariableType( + toVariablePayload(this.props.identifier, { newType: event.target.value as VariableType }) + ); + }; + + onLabelChange = (event: ChangeEvent) => { + event.preventDefault(); + this.props.changeVariableProp( + toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.target.value }) + ); + }; + + onHideChange = (event: ChangeEvent) => { + 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) => { + 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 ( +
+
+
General
+
+
+
+ Name + +
+
+ + Type + +
+ +
+
+
+ + {this.props.editor.errors.name && ( +
+ {this.props.editor.errors.name} +
+ )} + +
+
+ Label + +
+
+ Hide +
+ +
+
+
+
+ + {EditorToRender && } + + + +
+ {!newVariable && ( + + )} + {newVariable && ( + + )} +
+ +
+ ); + } +} + +const mapStateToProps: MapStateToProps = (state, ownProps) => ({ + editor: state.templating.editor, + variable: getVariable(ownProps.identifier.uuid!, state), +}); + +const mapDispatchToProps: MapDispatchToProps = { + variableEditorMount, + variableEditorUnMount, + changeVariableName, + changeVariableProp, + onEditorUpdate, + onEditorAdd, + changeVariableType, +}; + +export const VariableEditorEditor = connectWithStore( + VariableEditorEditorUnConnected, + mapStateToProps, + mapDispatchToProps +); diff --git a/public/app/features/templating/editor/VariableEditorList.tsx b/public/app/features/templating/editor/VariableEditorList.tsx new file mode 100644 index 00000000000..9cfb670b358 --- /dev/null +++ b/public/app/features/templating/editor/VariableEditorList.tsx @@ -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) => 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 { + 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 ( +
+
+ {this.props.variables.length === 0 && ( +
+ + 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 + + Templating documentation + + for more information. +

`, + }} + infoBoxTitle="What do variables do?" + onClick={this.props.onAddClick} + /> +
+ )} + + {this.props.variables.length > 0 && ( +
+ + + + + + + + + {this.props.variables.map((state, index) => { + const variable = state as QueryVariableModel; + return ( + + + + + + + + + + ); + })} + +
VariableDefinition +
+ this.onEditClick(event, toVariableIdentifier(variable))} + className="pointer template-variable" + aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowNameFields( + variable.name + )} + > + {variable.name} + + 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} + + {index > 0 && ( + this.onChangeVariableOrder(event, variable, MoveType.up)} + className="pointer fa fa-arrow-up" + aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowArrowUpButtons( + variable.name + )} + /> + )} + + {index < this.props.variables.length - 1 && ( + this.onChangeVariableOrder(event, variable, MoveType.down)} + className="pointer fa fa-arrow-down" + aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowArrowDownButtons( + variable.name + )} + /> + )} + + this.onDuplicateVariable(event, toVariableIdentifier(variable))} + className="btn btn-inverse btn-small" + aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowDuplicateButtons( + variable.name + )} + > + Duplicate + + + this.onRemoveVariable(event, toVariableIdentifier(variable))} + className="btn btn-danger btn-small" + aria-label={e2e.pages.Dashboard.Settings.Variables.List.selectors.tableRowRemoveButtons( + variable.name + )} + > + + +
+
+ )} +
+
+ ); + } +} diff --git a/public/app/features/templating/editor/VariableValuesPreview.tsx b/public/app/features/templating/editor/VariableValuesPreview.tsx new file mode 100644 index 00000000000..945eb8b812d --- /dev/null +++ b/public/app/features/templating/editor/VariableValuesPreview.tsx @@ -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 = ({ variable }) => { + const [previewLimit, setPreviewLimit] = useState(20); + const [previewOptions, setPreviewOptions] = useState([]); + 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 ( +
+
Preview of values
+
+ {previewOptions.map((o, index) => ( +
+ + {o.text} + +
+ ))} + {previewOptions.length > previewLimit && ( + + )} +
+
+ ); +}; +VariableValuesPreview.displayName = 'VariableValuesPreview'; diff --git a/public/app/features/templating/editor/actions.ts b/public/app/features/templating/editor/actions.ts new file mode 100644 index 00000000000..cfa498639d2 --- /dev/null +++ b/public/app/features/templating/editor/actions.ts @@ -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 => { + return async dispatch => { + dispatch(variableEditorMounted({ name: getVariable(identifier.uuid!).name })); + }; +}; + +export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResult => { + 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 => { + 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 => { + 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 => { + 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 => (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(identifier, { global, model, index }) + ) + ); + dispatch(setIdInEditor({ id: identifier.uuid })); +}; + +export const switchToEditMode = (identifier: VariableIdentifier): ThunkResult => dispatch => { + dispatch(setIdInEditor({ id: identifier.uuid })); +}; + +export const switchToListMode = (): ThunkResult => dispatch => { + dispatch(clearIdInEditor()); +}; diff --git a/public/app/features/templating/editor/reducer.test.ts b/public/app/features/templating/editor/reducer.test.ts new file mode 100644 index 00000000000..a956f3cd050 --- /dev/null +++ b/public/app/features/templating/editor/reducer.test.ts @@ -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() + .givenReducer(variableEditorReducer, { ...initialVariableEditorState }) + .whenActionIsDispatched(setIdInEditor(payload)) + .thenStateShouldEqual({ + ...initialVariableEditorState, + id: '0', + }); + }); + }); + + describe('when clearIdInEditor is dispatched', () => { + it('then state should be correct ', () => { + reducerTester() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .givenReducer(variableEditorReducer, { ...initialVariableEditorState }) + .whenActionIsDispatched(changeVariableEditorExtended(payload)) + .thenStateShouldEqual({ + ...initialVariableEditorState, + extended: { + someProp: [{}], + }, + }); + }); + }); +}); diff --git a/public/app/features/templating/editor/reducer.ts b/public/app/features/templating/editor/reducer.ts new file mode 100644 index 00000000000..6998cb216a6 --- /dev/null +++ b/public/app/features/templating/editor/reducer.ts @@ -0,0 +1,85 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { VariablePayload } from '../state/types'; + +type VariableEditorExtension = { [P in keyof ExtendedProps]: ExtendedProps[P] }; +export interface VariableEditorState { + id: string; + name: string; + errors: Record; + isValid: boolean; + extended: VariableEditorExtension | 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) => { + state.id = ''; + }, + variableEditorMounted: (state: VariableEditorState, action: PayloadAction<{ name: string }>) => { + state.name = action.payload.name; + }, + variableEditorUnMounted: (state: VariableEditorState, action: PayloadAction) => { + return initialVariableEditorState; + }, + changeVariableNameSucceeded: (state: VariableEditorState, action: PayloadAction>) => { + 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; diff --git a/public/app/features/templating/editor/types.ts b/public/app/features/templating/editor/types.ts new file mode 100644 index 00000000000..73ff373cc47 --- /dev/null +++ b/public/app/features/templating/editor/types.ts @@ -0,0 +1,12 @@ +import { VariableModel } from '../variable'; + +export interface OnPropChangeArguments { + propName: keyof Model; + propValue: any; + updateOptions?: boolean; +} + +export interface VariableEditorProps { + variable: Model; + onPropChange: (args: OnPropChangeArguments) => void; +} diff --git a/public/app/features/templating/guard.ts b/public/app/features/templating/guard.ts new file mode 100644 index 00000000000..8235a6957ac --- /dev/null +++ b/public/app/features/templating/guard.ts @@ -0,0 +1,5 @@ +import { VariableModel, QueryVariableModel } from './variable'; + +export const isQuery = (model: VariableModel): model is QueryVariableModel => { + return model.type === 'query'; +}; diff --git a/public/app/features/templating/pickers/OptionsPicker/OptionsPicker.tsx b/public/app/features/templating/pickers/OptionsPicker/OptionsPicker.tsx new file mode 100644 index 00000000000..f85b132d656 --- /dev/null +++ b/public/app/features/templating/pickers/OptionsPicker/OptionsPicker.tsx @@ -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 {} + +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 { + 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 ( +
+ {this.renderLink(showOptions, variable)} + {this.renderOptions(showOptions, picker)} +
+ ); + } + + renderLink(showOptions: boolean, variable: VariableWithMultiSupport) { + if (showOptions) { + return null; + } + + const linkText = getLinkText(variable); + const tags = getSelectedTags(variable); + + return ; + } + + renderOptions(showOptions: boolean, picker: OptionsPickerState) { + if (!showOptions) { + return null; + } + + return ( + + + + + ); + } +} + +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 = { + showOptions, + commitChangesToVariable, + filterOrSearchOptions, + toggleAllOptions, + toggleOption, + toggleAndFetchTag, + navigateOptions, +}; + +const mapStateToProps: MapStateToProps = state => ({ + picker: state.templating.optionsPicker, +}); + +export const OptionsPicker = connect(mapStateToProps, mapDispatchToProps)(OptionsPickerUnconnected); +OptionsPicker.displayName = 'OptionsPicker'; diff --git a/public/app/features/templating/pickers/OptionsPicker/actions.test.ts b/public/app/features/templating/pickers/OptionsPicker/actions.test.ts new file mode 100644 index 00000000000..95dcfff4661 --- /dev/null +++ b/public/app/features/templating/pickers/OptionsPicker/actions.test.ts @@ -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 { + 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, + }; +} diff --git a/public/app/features/templating/pickers/OptionsPicker/actions.ts b/public/app/features/templating/pickers/OptionsPicker/actions.ts new file mode 100644 index 00000000000..2f7e4486181 --- /dev/null +++ b/public/app/features/templating/pickers/OptionsPicker/actions.ts @@ -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 => { + 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 => { + return async (dispatch, getState) => { + const { uuid } = getState().templating.optionsPicker; + const { query, options } = getVariable(uuid!, getState()); + dispatch(updateSearchQuery(searchQuery)); + + if (containsSearchFilter(query)) { + return searchForOptionsWithDebounce(dispatch, getState, searchQuery); + } + return dispatch(updateOptionsAndFilter(options)); + }; +}; + +export const commitChangesToVariable = (): ThunkResult => { + return async (dispatch, getState) => { + const picker = getState().templating.optionsPicker; + const existing = getVariable(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(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 => { + return (dispatch, getState) => { + const { uuid, highlightIndex } = getState().templating.optionsPicker; + const variable = getVariable(uuid, getState()); + const option = variable.options[highlightIndex]; + dispatch(toggleOption({ option, forceSelect: false, clearOthers })); + }; +}; + +export const toggleAndFetchTag = (tag: VariableTag): ThunkResult => { + 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> => { + return async (dispatch, getState) => { + const picker = getState().templating.optionsPicker; + const variable = getVariable(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(uuid, getState()); + + const adapter = variableAdapters.get(existing.type); + await adapter.updateOptions(existing, searchQuery); + + const updated = getVariable(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, + }; +} diff --git a/public/app/features/templating/pickers/OptionsPicker/reducer.test.ts b/public/app/features/templating/pickers/OptionsPicker/reducer.test.ts new file mode 100644 index 00000000000..cf39b0bf877 --- /dev/null +++ b/public/app/features/templating/pickers/OptionsPicker/reducer.test.ts @@ -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) => { + 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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .givenReducer(optionsPickerReducer, cloneDeep(initialState)) + .whenActionIsDispatched(updateSearchQuery(searchQuery)) + .thenStateShouldEqual({ + ...initialState, + queryValue: searchQuery, + }); + }); + }); +}); diff --git a/public/app/features/templating/pickers/OptionsPicker/reducer.ts b/public/app/features/templating/pickers/OptionsPicker/reducer.ts new file mode 100644 index 00000000000..5a866a5868d --- /dev/null +++ b/public/app/features/templating/pickers/OptionsPicker/reducer.ts @@ -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): 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): 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): 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): 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): OptionsPickerState => { + state.queryValue = action.payload; + return state; + }, + updateOptionsAndFilter: (state, action: PayloadAction): 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): 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; diff --git a/public/app/features/templating/pickers/PickerRenderer.tsx b/public/app/features/templating/pickers/PickerRenderer.tsx new file mode 100644 index 00000000000..6b59a17a5cd --- /dev/null +++ b/public/app/features/templating/pickers/PickerRenderer.tsx @@ -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 => { + 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
Couldn't load variable
; + } + + return ( +
+ {props.variable.hide === VariableHide.dontHide && ( + + )} + {props.variable.hide !== VariableHide.hideVariable && PickerToRender && ( + + )} +
+ ); +}; diff --git a/public/app/features/templating/pickers/index.ts b/public/app/features/templating/pickers/index.ts new file mode 100644 index 00000000000..f03c7623d9a --- /dev/null +++ b/public/app/features/templating/pickers/index.ts @@ -0,0 +1 @@ +export { OptionsPicker } from './OptionsPicker/OptionsPicker'; diff --git a/public/app/features/templating/pickers/shared/VariableInput.tsx b/public/app/features/templating/pickers/shared/VariableInput.tsx new file mode 100644 index 00000000000..37826df396f --- /dev/null +++ b/public/app/features/templating/pickers/shared/VariableInput.tsx @@ -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 { + onKeyDown = (event: React.KeyboardEvent) => { + if (NavigationKey[event.keyCode]) { + const clearOthers = event.ctrlKey || event.metaKey || event.shiftKey; + this.props.onNavigate(event.keyCode as NavigationKey, clearOthers); + } + }; + + onChange = (event: React.ChangeEvent) => { + 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 ( + { + 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} + /> + ); + } +} diff --git a/public/app/features/templating/pickers/shared/VariableLink.tsx b/public/app/features/templating/pickers/shared/VariableLink.tsx new file mode 100644 index 00000000000..a298be8c0fe --- /dev/null +++ b/public/app/features/templating/pickers/shared/VariableLink.tsx @@ -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 { + onClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + this.props.onClick(); + }; + + render() { + const { tags = [], text } = this.props; + + return ( + + {text} + {tags.map(tag => { + const { color, borderColor } = getTagColorsFromName(tag.text.toString()); + return ( + + +     {tag.text} + + + ); + })} + + + ); + } +} diff --git a/public/app/features/templating/pickers/shared/VariableOptions.tsx b/public/app/features/templating/pickers/shared/VariableOptions.tsx new file mode 100644 index 00000000000..3bc2bba9af4 --- /dev/null +++ b/public/app/features/templating/pickers/shared/VariableOptions.tsx @@ -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 { + onToggle = (option: VariableOption) => (event: React.MouseEvent) => { + const clearOthers = event.shiftKey || event.ctrlKey || event.metaKey; + this.handleEvent(event); + this.props.onToggle(option, clearOthers); + }; + + onToggleAll = (event: React.MouseEvent) => { + this.handleEvent(event); + this.props.onToggleAll(); + }; + + onToggleTag = (tag: VariableTag) => (event: React.MouseEvent) => { + this.handleEvent(event); + this.props.onToggleTag(tag); + }; + + handleEvent(event: React.MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + render() { + const { multi, values, tags } = this.props; + + return ( +
+
+
+ {this.renderMultiToggle()} + {values.map((option, index) => this.renderOption(option, index))} +
+ {this.renderTags(tags)} +
+
+ ); + } + + renderTags(tags: VariableTag[]) { + if (tags.length === 0) { + return null; + } + + return ( +
+
Tags
+ {tags.map(tag => this.renderTag(tag))} +
+ ); + } + + renderTag(tag: VariableTag) { + const { color, borderColor } = getTagColorsFromName(tag.text.toString()); + + return ( + + + + {tag.text}    + + + ); + } + + 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 ( + + + + {option.text} + + + ); + } + + renderMultiToggle() { + const { multi, selectedValues } = this.props; + + if (!multi) { + return null; + } + + return ( + + 1 + ? 'variable-options-column-header many-selected' + : 'variable-options-column-header' + }`} + onClick={this.onToggleAll} + data-placement="top" + > + + Selected ({selectedValues.length}) + + + ); + } +} diff --git a/public/app/features/templating/pickers/types.ts b/public/app/features/templating/pickers/types.ts new file mode 100644 index 00000000000..b69546c562f --- /dev/null +++ b/public/app/features/templating/pickers/types.ts @@ -0,0 +1,13 @@ +import { VariableModel } from '../variable'; + +export interface VariablePickerProps { + variable: Model; +} + +export enum NavigationKey { + moveUp = 38, + moveDown = 40, + select = 32, + cancel = 27, + selectAndClose = 13, +} diff --git a/public/app/features/templating/query/QueryVariableEditor.tsx b/public/app/features/templating/query/QueryVariableEditor.tsx new file mode 100644 index 00000000000..b1c97d54479 --- /dev/null +++ b/public/app/features/templating/query/QueryVariableEditor.tsx @@ -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 {} + +interface ConnectedProps { + editor: VariableEditorState; +} + +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 { + state: State = { + regex: null, + tagsQuery: null, + tagValuesQuery: null, + }; + + async componentDidMount() { + await this.props.initQueryVariableEditor(toVariableIdentifier(this.props.variable)); + } + + componentDidUpdate(prevProps: Readonly): 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) => { + 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) => { + this.setState({ regex: event.target.value }); + }; + + onRegExBlur = async (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'regex', propValue: event.target.value, updateOptions: true }); + }; + + onTagsQueryChange = async (event: ChangeEvent) => { + this.setState({ tagsQuery: event.target.value }); + }; + + onTagsQueryBlur = async (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'tagsQuery', propValue: event.target.value, updateOptions: true }); + }; + + onTagValuesQueryChange = async (event: ChangeEvent) => { + this.setState({ tagValuesQuery: event.target.value }); + }; + + onTagValuesQueryBlur = async (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'tagValuesQuery', propValue: event.target.value, updateOptions: true }); + }; + + onRefreshChange = (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'refresh', propValue: parseInt(event.target.value, 10) }); + }; + + onSortChange = async (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'sort', propValue: parseInt(event.target.value, 10), updateOptions: true }); + }; + + onSelectionOptionsChange = async ({ propValue, propName }: OnPropChangeArguments) => { + this.props.onPropChange({ propName, propValue, updateOptions: true }); + }; + + onUseTagsChange = async (event: ChangeEvent) => { + this.props.onPropChange({ propName: 'useTags', propValue: event.target.checked, updateOptions: true }); + }; + + render() { + const VariableQueryEditor = this.props.editor.extended?.VariableQueryEditor; + return ( + <> +
+
Query Options
+
+
+ Data source +
+ +
+
+ +
+ + Refresh + +
+ +
+
+
+ + {VariableQueryEditor && this.props.editor.extended?.dataSource && ( + + )} + +
+ + Regex + + +
+
+ + Sort + +
+ +
+
+
+ + + +
+
Value groups/tags (Experimental feature)
+
+ +
+ {this.props.variable.useTags && ( + <> +
+ Tags query + +
+
+
  • Tag values query
  • + +
    + + )} +
    + + ); + } +} + +const mapStateToProps: MapStateToProps = (state, ownProps) => ({ + editor: state.templating.editor as VariableEditorState, +}); + +const mapDispatchToProps: MapDispatchToProps = { + initQueryVariableEditor, + changeQueryVariableDataSource, + changeQueryVariableQuery, +}; + +export const QueryVariableEditor = connectWithStore( + QueryVariableEditorUnConnected, + mapStateToProps, + mapDispatchToProps +); diff --git a/public/app/features/templating/query/actions.test.ts b/public/app/features/templating/query/actions.test.ts new file mode 100644 index 00000000000..f8194b1fa5b --- /dev/null +++ b/public/app/features/templating/query/actions.test.ts @@ -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 = { + 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 = { + [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 { + 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, + }; +} diff --git a/public/app/features/templating/query/actions.ts b/public/app/features/templating/query/actions.ts new file mode 100644 index 00000000000..3f856ffcf57 --- /dev/null +++ b/public/app/features/templating/query/actions.ts @@ -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 => { + return async (dispatch, getState) => { + const variableInState = getVariable(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 => 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(identifier.uuid!, getState()); + if (!variable.datasource) { + return; + } + dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource)); +}; + +export const changeQueryVariableDataSource = ( + identifier: VariableIdentifier, + name: string | null +): ThunkResult => { + 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 => async (dispatch, getState) => { + const variableInState = getVariable(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); +}; diff --git a/public/app/features/templating/query/adapter.ts b/public/app/features/templating/query/adapter.ts new file mode 100644 index 00000000000..d0f5b6078c8 --- /dev/null +++ b/public/app/features/templating/query/adapter.ts @@ -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 => { + 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; + }, + }; +}; diff --git a/public/app/features/templating/query/reducer.test.ts b/public/app/features/templating/query/reducer.test.ts new file mode 100644 index 00000000000..589f6b65c77 --- /dev/null +++ b/public/app/features/templating/query/reducer.test.ts @@ -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() + .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() + .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() + .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() + .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() + .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() + .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() + .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, + }); + }); + }); +}); diff --git a/public/app/features/templating/query/reducer.ts b/public/app/features/templating/query/reducer.ts new file mode 100644 index 00000000000..799e3128257 --- /dev/null +++ b/public/app/features/templating/query/reducer.ts @@ -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 | 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>) => { + const results = action.payload.data; + const instanceState = getInstanceState(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>) => { + const instanceState = getInstanceState(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; diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index 3a9838732e2..ae0527e18c4 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -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 => { diff --git a/public/app/features/templating/specs/query_variable.test.ts b/public/app/features/templating/specs/query_variable.test.ts index e391c0cb50c..6c42bfc1acb 100644 --- a/public/app/features/templating/specs/query_variable.test.ts +++ b/public/app/features/templating/specs/query_variable.test.ts @@ -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(() => { diff --git a/public/app/features/templating/specs/variable.test.ts b/public/app/features/templating/specs/variable.test.ts index 1d78d65719d..35b374f712e 100644 --- a/public/app/features/templating/specs/variable.test.ts +++ b/public/app/features/templating/specs/variable.test.ts @@ -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 diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts index bb0dee1e131..027d9696c75 100644 --- a/public/app/features/templating/specs/variable_srv_init.test.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -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: [ diff --git a/public/app/features/templating/state/actions.test.ts b/public/app/features/templating/state/actions.test.ts new file mode 100644 index 00000000000..5849c44bfe0 --- /dev/null +++ b/public/app/features/templating/state/actions.test.ts @@ -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; + }); + } + ); + }); + }); +}); diff --git a/public/app/features/templating/state/actions.ts b/public/app/features/templating/state/actions.ts new file mode 100644 index 00000000000..c803f44339a --- /dev/null +++ b/public/app/features/templating/state/actions.ts @@ -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 => { + 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> = []; + + 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 => { + 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 => { + 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 => { + 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(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 => { + return async (dispatch, getState) => { + const variableInState = getVariable(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 => { + 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 => { + 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> = []; + 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; +}; diff --git a/public/app/features/templating/state/helpers.ts b/public/app/features/templating/state/helpers.ts new file mode 100644 index 00000000000..52f44fefc23 --- /dev/null +++ b/public/app/features/templating/state/helpers.ts @@ -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 => { + const variables: Record = {}; + + 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 = ( + adapter: VariableAdapter, + variableOverrides: Partial = {} +) => { + 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, + }); diff --git a/public/app/features/templating/state/processVariable.test.ts b/public/app/features/templating/state/processVariable.test.ts new file mode 100644 index 00000000000..f18dcb62ac7 --- /dev/null +++ b/public/app/features/templating/state/processVariable.test.ts @@ -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' })) + ); + }); + }); + }); + }); + }); +}); diff --git a/public/app/features/templating/state/reducers.test.ts b/public/app/features/templating/state/reducers.test.ts new file mode 100644 index 00000000000..b387722dc4b --- /dev/null +++ b/public/app/features/templating/state/reducers.test.ts @@ -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() + .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 = { + 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('mockAction'); + reducerTester() + .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 = { + 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('mockAction'); + reducerTester() + .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 = { + 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('mockAction'); + reducerTester() + .givenReducer(variablesReducer, initialState) + .whenActionIsDispatched(mockAction('mocked')) + .thenStateShouldEqual(initialState); + expect(variableAdapter.reducer).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/public/app/features/templating/state/reducers.ts b/public/app/features/templating/state/reducers.ts new file mode 100644 index 00000000000..a89733af8ed --- /dev/null +++ b/public/app/features/templating/state/reducers.ts @@ -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; + optionsPicker: OptionsPickerState; + editor: VariableEditorState; +} + +export default { + templating: combineReducers({ + optionsPicker: optionsPickerReducer, + editor: variableEditorReducer, + variables: variablesReducer, + }), +}; diff --git a/public/app/features/templating/state/selectors.ts b/public/app/features/templating/state/selectors.ts new file mode 100644 index 00000000000..e84f54f6af6 --- /dev/null +++ b/public/app/features/templating/state/selectors.ts @@ -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 = ( + 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!); +}; diff --git a/public/app/features/templating/state/sharedReducer.test.ts b/public/app/features/templating/state/sharedReducer.test.ts new file mode 100644 index 00000000000..5cd8d6906fc --- /dev/null +++ b/public/app/features/templating/state/sharedReducer.test.ts @@ -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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .givenReducer(sharedReducer, cloneDeep(initialState)) + .whenActionIsDispatched(changeVariableProp(payload)) + .thenStateShouldEqual({ + ...initialState, + '0': { + ...initialState[0], + name: 'Updated name', + }, + }); + }); + }); +}); diff --git a/public/app/features/templating/state/sharedReducer.ts b/public/app/features/templating/state/sharedReducer.ts new file mode 100644 index 00000000000..325e5223e4e --- /dev/null +++ b/public/app/features/templating/state/sharedReducer.ts @@ -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>) => { + 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) => { + const instanceState = getInstanceState(state, action.payload.uuid!); + instanceState.initLock = new Deferred(); + }, + resolveInitLock: (state: VariablesState, action: PayloadAction) => { + const instanceState = getInstanceState(state, action.payload.uuid!); + instanceState.initLock?.resolve(); + }, + removeInitLock: (state: VariablesState, action: PayloadAction) => { + const instanceState = getInstanceState(state, action.payload.uuid!); + instanceState.initLock = null; + }, + removeVariable: (state: VariablesState, action: PayloadAction>) => { + 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>) => { + const newUuid = action.payload.data?.newUuid ?? v4(); + const original = cloneDeep(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> + ) => { + 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) => { + const uuid = action.payload.uuid!; + const emptyVariable = cloneDeep(state[EMPTY_UUID]); + state[uuid!] = { + ...cloneDeep(variableAdapters.get(action.payload.type).initialState), + ...emptyVariable, + uuid, + index: emptyVariable.index, + }; + }, + changeVariableType: (state: VariablesState, action: PayloadAction>) => { + 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> + ) => { + const instanceState = getInstanceState(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, 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> + ) => { + const instanceState = getInstanceState(state, action.payload.uuid!); + (instanceState as Record)[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); +}; diff --git a/public/app/features/templating/state/types.ts b/public/app/features/templating/state/types.ts new file mode 100644 index 00000000000..4215e8898c2 --- /dev/null +++ b/public/app/features/templating/state/types.ts @@ -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 = (state: VariablesState, uuid: string) => { + return state[uuid] as Model; +}; + +export interface VariableIdentifier { + type: VariableType; + uuid: string; +} + +export interface VariablePayload extends VariableIdentifier { + data: T; +} + +export interface AddVariable { + 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( + identifier: VariableIdentifier, + data?: T +): VariablePayload; +export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; +export function toVariablePayload( + obj: VariableIdentifier | VariableModel, + data?: T +): VariablePayload { + return { type: obj.type, uuid: obj.uuid!, data: data as T }; +} diff --git a/public/app/features/templating/state/variablesReducer.ts b/public/app/features/templating/state/variablesReducer.ts new file mode 100644 index 00000000000..f454b3c8855 --- /dev/null +++ b/public/app/features/templating/state/variablesReducer.ts @@ -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 {} + +export const initialVariablesState: VariablesState = {}; + +export const variablesReducer = ( + state: VariablesState = initialVariablesState, + action: PayloadAction +): 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); + + 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; +}; diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 4d55bdc8ea1..63df7ec7b21 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -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 '' + match + ''; } 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(); diff --git a/public/app/features/templating/textbox/TextBoxVariableEditor.tsx b/public/app/features/templating/textbox/TextBoxVariableEditor.tsx new file mode 100644 index 00000000000..6a3b773462e --- /dev/null +++ b/public/app/features/templating/textbox/TextBoxVariableEditor.tsx @@ -0,0 +1,34 @@ +import React, { ChangeEvent, PureComponent } from 'react'; +import { TextBoxVariableModel } from '../variable'; +import { VariableEditorProps } from '../editor/types'; + +export interface Props extends VariableEditorProps {} +export class TextBoxVariableEditor extends PureComponent { + onQueryChange = (event: ChangeEvent) => { + event.preventDefault(); + this.props.onPropChange({ propName: 'query', propValue: event.target.value, updateOptions: false }); + }; + onQueryBlur = (event: ChangeEvent) => { + event.preventDefault(); + this.props.onPropChange({ propName: 'query', propValue: event.target.value, updateOptions: true }); + }; + render() { + const { query } = this.props.variable; + return ( +
    +
    Text options
    +
    + Default value + +
    +
    + ); + } +} diff --git a/public/app/features/templating/textbox/TextBoxVariablePicker.tsx b/public/app/features/templating/textbox/TextBoxVariablePicker.tsx new file mode 100644 index 00000000000..fbc0dd1f234 --- /dev/null +++ b/public/app/features/templating/textbox/TextBoxVariablePicker.tsx @@ -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 {} + +export class TextBoxVariablePicker extends PureComponent { + onQueryChange = (event: ChangeEvent) => { + dispatch( + changeVariableProp(toVariablePayload(this.props.variable, { propName: 'query', propValue: event.target.value })) + ); + }; + + onQueryBlur = (event: FocusEvent) => { + if (this.props.variable.current.value !== this.props.variable.query) { + variableAdapters.get(this.props.variable.type).updateOptions(this.props.variable); + } + }; + + onQueryKeyDown = (event: KeyboardEvent) => { + 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 ( + + ); + } +} diff --git a/public/app/features/templating/textbox/actions.test.ts b/public/app/features/templating/textbox/actions.test.ts new file mode 100644 index 00000000000..47ff72ed0fc --- /dev/null +++ b/public/app/features/templating/textbox/actions.test.ts @@ -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; + }); + }); + }); +}); diff --git a/public/app/features/templating/textbox/actions.ts b/public/app/features/templating/textbox/actions.ts new file mode 100644 index 00000000000..58fdc9e73af --- /dev/null +++ b/public/app/features/templating/textbox/actions.ts @@ -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 => { + return async (dispatch, getState) => { + await dispatch(createTextBoxOptions(toVariablePayload(identifier))); + const variableInState = getVariable(identifier.uuid!, getState()); + await variableAdapters.get(identifier.type).setValue(variableInState, variableInState.options[0], true); + }; +}; diff --git a/public/app/features/templating/textbox/adapter.ts b/public/app/features/templating/textbox/adapter.ts new file mode 100644 index 00000000000..30143f128ea --- /dev/null +++ b/public/app/features/templating/textbox/adapter.ts @@ -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 => { + 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; + }, + }; +}; diff --git a/public/app/features/templating/textbox/reducer.test.ts b/public/app/features/templating/textbox/reducer.test.ts new file mode 100644 index 00000000000..f06b2b52fb4 --- /dev/null +++ b/public/app/features/templating/textbox/reducer.test.ts @@ -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() + .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() + .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, + }); + }); + }); +}); diff --git a/public/app/features/templating/textbox/reducer.ts b/public/app/features/templating/textbox/reducer.ts new file mode 100644 index 00000000000..f5f0d5ff43a --- /dev/null +++ b/public/app/features/templating/textbox/reducer.ts @@ -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) => { + const instanceState = getInstanceState(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; diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 09c18b34ddb..461eb24082a 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -1,7 +1,9 @@ import _ from 'lodash'; -import { assignModelProperties } from 'app/core/utils/model_utils'; import { ScopedVars } from '@grafana/data'; +import { assignModelProperties } from 'app/core/utils/model_utils'; +import { Deferred } from '../../core/utils/deferred'; + /* * This regex matches 3 types of variable reference with an optional format specifier * \$(\w+) $var1 @@ -52,8 +54,8 @@ export enum VariableRefresh { export enum VariableHide { dontHide, - hideVariable, hideLabel, + hideVariable, } export enum VariableSort { @@ -67,7 +69,10 @@ export enum VariableSort { } export interface VariableTag { + selected: boolean; text: string | string[]; + values?: any[]; + valuesText?: string; } export interface VariableOption { @@ -75,6 +80,7 @@ export interface VariableOption { text: string | string[]; value: string | string[]; isNone?: boolean; + tags?: VariableTag[]; } export type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom'; @@ -87,23 +93,10 @@ export interface AdHocVariableFilter { } export interface AdHocVariableModel extends VariableModel { - datasource: string; + datasource: string | null; filters: AdHocVariableFilter[]; } -export interface CustomVariableModel extends VariableWithOptions { - allValue: string; - includeAll: boolean; - multi: boolean; -} - -export interface DataSourceVariableModel extends VariableWithOptions { - includeAll: boolean; - multi: boolean; - refresh: VariableRefresh; - regex: string; -} - export interface IntervalVariableModel extends VariableWithOptions { auto: boolean; auto_min: string; @@ -111,25 +104,34 @@ export interface IntervalVariableModel extends VariableWithOptions { refresh: VariableRefresh; } -export interface QueryVariableModel extends VariableWithOptions { - allValue: string; - datasource: string; - definition: string; - includeAll: boolean; - multi: boolean; +export interface CustomVariableModel extends VariableWithMultiSupport {} + +export interface DataSourceVariableModel extends VariableWithMultiSupport { refresh: VariableRefresh; regex: string; +} + +export interface QueryVariableModel extends DataSourceVariableModel { + datasource: string | null; + definition: string; sort: VariableSort; tags: VariableTag[]; tagsQuery: string; tagValuesQuery: string; useTags: boolean; + queryValue?: string; } export interface TextBoxVariableModel extends VariableWithOptions {} export interface ConstantVariableModel extends VariableWithOptions {} +export interface VariableWithMultiSupport extends VariableWithOptions { + multi: boolean; + includeAll: boolean; + allValue?: string | null; +} + export interface VariableWithOptions extends VariableModel { current: VariableOption; options: VariableOption[]; @@ -137,11 +139,15 @@ export interface VariableWithOptions extends VariableModel { } export interface VariableModel { + uuid?: string; // only exists for variables in redux state + global?: boolean; // only exists for variables in redux state type: VariableType; name: string; - label: string; + label: string | null; hide: VariableHide; skipUrlSync: boolean; + index?: number; + initLock?: Deferred | null; } export interface VariableActions { diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index 611b0901236..d4170d61a60 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -1,7 +1,6 @@ // Libaries -import angular, { IQService, ILocationService, auto, IPromise } from 'angular'; +import angular, { auto, ILocationService, IPromise, IQService } from 'angular'; import _ from 'lodash'; - // Utils & Services import coreModule from 'app/core/core_module'; import { variableTypes } from './variable'; @@ -9,16 +8,15 @@ import { Graph } from 'app/core/utils/dag'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; - // Types -import { TimeRange, AppEvents } from '@grafana/data'; +import { AppEvents, TimeRange } from '@grafana/data'; import { CoreEvents } from 'app/types'; import { UrlQueryMap } from '@grafana/runtime'; import { appEvents, contextSrv } from 'app/core/core'; export class VariableSrv { dashboard: DashboardModel; - variables: any[]; + variables: any[] = []; /** @ngInject */ constructor( diff --git a/public/app/store/store.ts b/public/app/store/store.ts index f306e34247d..c133cff1d5f 100644 --- a/public/app/store/store.ts +++ b/public/app/store/store.ts @@ -6,3 +6,19 @@ export let store: Store; export function setStore(newStore: Store) { store = newStore; } + +export function getState(): StoreState { + if (!store || !store.getState) { + return {} as StoreState; // used by tests + } + + return store.getState(); +} + +export function dispatch(action: any) { + if (!store || !store.getState) { + return; + } + + return store.dispatch(action); +} diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 54f7d153a3a..1f8a94841f9 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -7,9 +7,9 @@ import { AlertRulesState } from './alerting'; import { TeamsState, TeamState } from './teams'; import { FolderState } from './folders'; import { DashboardState } from './dashboard'; -import { DataSourcesState, DataSourceSettingsState } from './datasources'; +import { DataSourceSettingsState, DataSourcesState } from './datasources'; import { ExploreState } from './explore'; -import { UsersState, UserState, UserAdminState } from './user'; +import { UserAdminState, UsersState, UserState } from './user'; import { OrganizationState } from './organization'; import { AppNotificationsState } from './appNotifications'; import { PluginsState } from './plugins'; @@ -18,6 +18,7 @@ import { LdapState } from './ldap'; import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers'; import { PanelEditorStateNew } from '../features/dashboard/components/PanelEditor/state/reducers'; import { ApiKeysState } from './apiKeys'; +import { TemplatingState } from '../features/templating/state/reducers'; export interface StoreState { navIndex: NavIndex; @@ -41,6 +42,7 @@ export interface StoreState { ldap: LdapState; apiKeys: ApiKeysState; userAdmin: UserAdminState; + templating: TemplatingState; } /* diff --git a/public/e2e-tests/integration/queryVariableCrud.spec.ts b/public/e2e-tests/integration/queryVariableCrud.spec.ts index 2bb0aa8705a..8a6a9605ac9 100644 --- a/public/e2e-tests/integration/queryVariableCrud.spec.ts +++ b/public/e2e-tests/integration/queryVariableCrud.spec.ts @@ -20,11 +20,24 @@ const assertDefaultsForNewVariable = () => { .get('option:selected') .should('have.text', ''); }); - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect().within(select => { - e2e() - .get('option:selected') - .should('not.exist'); - }); + + e2e() + .window() + .then((win: any) => { + let chainer = 'not.exist'; + let value: string = undefined; + if (win.grafanaBootData.settings.featureToggles.newVariables) { + chainer = 'have.text'; + value = ''; + } + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect().within(select => { + e2e() + .get('option:selected') + .should(chainer, value); + }); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().should('not.exist'); e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelect().within(select => { e2e() @@ -69,9 +82,17 @@ const createQueryVariable = ({ name, label, dataSourceName, query }: CreateQuery e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().should('be.visible'); e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().type(name); e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label); - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() - .select(`string:${dataSourceName}`) - .blur(); + e2e() + .window() + .then((win: any) => { + let text = `string:${dataSourceName}`; + if (win.grafanaBootData.settings.featureToggles.newVariables) { + text = `${dataSourceName}`; + } + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .select(text) + .blur(); + }); e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() .within(input => { expect(input.attr('placeholder')).equals('metric name or tags query'); @@ -102,15 +123,19 @@ const createQueryVariable = ({ name, label, dataSourceName, query }: CreateQuery logSection('Creating a Query Variable with required, OK!'); }; -const assertVariableTableRow = ({ name, query }: QueryVariableData) => { +const assertVariableTableRow = ({ name, query }: QueryVariableData, index: number, length: number) => { e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields(name) .should('exist') .contains(name); e2e.pages.Dashboard.Settings.Variables.List.tableRowDefinitionFields(name) .should('exist') .contains(query); - e2e.pages.Dashboard.Settings.Variables.List.tableRowArrowUpButtons(name).should('exist'); - e2e.pages.Dashboard.Settings.Variables.List.tableRowArrowDownButtons(name).should('exist'); + if (index !== length - 1) { + e2e.pages.Dashboard.Settings.Variables.List.tableRowArrowDownButtons(name).should('exist'); + } + if (index !== 0) { + e2e.pages.Dashboard.Settings.Variables.List.tableRowArrowUpButtons(name).should('exist'); + } e2e.pages.Dashboard.Settings.Variables.List.tableRowDuplicateButtons(name).should('exist'); e2e.pages.Dashboard.Settings.Variables.List.tableRowRemoveButtons(name).should('exist'); }; @@ -126,7 +151,7 @@ const assertVariableTable = (args: QueryVariableData[]) => { }); for (let index = 0; index < args.length; index++) { - assertVariableTableRow(args[index]); + assertVariableTableRow(args[index], index, args.length); } logSection('Asserting variable table, Ok'); @@ -223,7 +248,7 @@ const assertDuplicateItem = (queryVariables: QueryVariableData[]) => { .should('have.length', queryVariables.length + 1); }); const newItem = { ...itemToDuplicate, name: `copy_of_${itemToDuplicate.name}` }; - assertVariableTableRow(newItem); + assertVariableTableRow(newItem, queryVariables.length - 1, queryVariables.length); e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields(newItem.name).click(); newItem.label = `copy_of_${itemToDuplicate.label}`; @@ -281,11 +306,10 @@ const assertDeleteItem = (queryVariables: QueryVariableData[]) => { }; const assertUpdateItem = (data: QueryVariableData[]) => { - logSection('Asserting variable update'); const queryVariables = [...data]; // updates an item to a constant variable instead const itemToUpdate = queryVariables[1]; - const updatedItem = { + let updatedItem = { ...itemToUpdate, name: `update_of_${itemToUpdate.name}`, label: `update_of_${itemToUpdate.label}`, @@ -293,6 +317,8 @@ const assertUpdateItem = (data: QueryVariableData[]) => { options: ['A constant'], selectedOption: 'undefined', }; + + logSection('Asserting variable update'); queryVariables[1] = updatedItem; e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click(); @@ -319,12 +345,22 @@ const assertUpdateItem = (data: QueryVariableData[]) => { e2e.pages.Dashboard.Toolbar.backArrow().click(); - assertVariableLabelAndComponent(queryVariables[1]); + e2e() + .window() + .then((win: any) => { + if (win.grafanaBootData.settings.featureToggles.newVariables) { + queryVariables[1].selectedOption = 'A constant'; + } else { + queryVariables[1].selectedOption = 'undefined'; + } + assertVariableLabelAndComponent(queryVariables[1]); + }); e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click(); e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click(); - assertVariableTableRow(queryVariables[1]); + assertVariableTableRow(queryVariables[1], 1, queryVariables.length); + queryVariables[1].selectedOption = 'A constant'; logSection('Asserting variable update, OK!'); diff --git a/public/test/core/redux/reducerTester.ts b/public/test/core/redux/reducerTester.ts index 0fd45d8d56a..08fe298a399 100644 --- a/public/test/core/redux/reducerTester.ts +++ b/public/test/core/redux/reducerTester.ts @@ -2,7 +2,12 @@ import { Reducer } from 'redux'; import { PayloadAction } from '@reduxjs/toolkit'; export interface Given { - givenReducer: (reducer: Reducer>, state: State, disableDeepFreeze?: boolean) => When; + givenReducer: ( + reducer: Reducer>, + state: State, + showDebugOutput?: boolean, + disableDeepFreeze?: boolean + ) => When; } export interface When { @@ -52,10 +57,12 @@ export const reducerTester = (): Given => { let reducerUnderTest: Reducer>; let resultingState: State; let initialState: State; + let showDebugOutput = false; const givenReducer = ( reducer: Reducer>, state: State, + debug = false, disableDeepFreeze = false ): When => { reducerUnderTest = reducer; @@ -63,6 +70,7 @@ export const reducerTester = (): Given => { if (!disableDeepFreeze) { initialState = deepFreeze(initialState); } + showDebugOutput = debug; return instance; }; @@ -74,12 +82,18 @@ export const reducerTester = (): Given => { }; const thenStateShouldEqual = (state: State): When => { - expect(state).toEqual(resultingState); + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } + expect(resultingState).toEqual(state); return instance; }; const thenStatePredicateShouldEqual = (predicate: (resultingState: State) => boolean): When => { + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } expect(predicate(resultingState)).toBe(true); return instance; diff --git a/public/test/core/redux/reduxTester.ts b/public/test/core/redux/reduxTester.ts new file mode 100644 index 00000000000..41976f7a7b4 --- /dev/null +++ b/public/test/core/redux/reduxTester.ts @@ -0,0 +1,121 @@ +import { AnyAction, configureStore, EnhancedStore, Reducer } from '@reduxjs/toolkit'; +import { Dispatch, Middleware, MiddlewareAPI } from 'redux'; + +import { StoreState } from '../../../app/types'; +import thunk from 'redux-thunk'; +import { setStore } from '../../../app/store/store'; + +export interface ReduxTesterGiven { + givenRootReducer: (rootReducer: Reducer) => ReduxTesterWhen; +} + +export interface ReduxTesterWhen { + whenActionIsDispatched: ( + action: any, + clearPreviousActions?: boolean + ) => ReduxTesterWhen & ReduxTesterThen; + whenAsyncActionIsDispatched: ( + action: any, + clearPreviousActions?: boolean + ) => Promise & ReduxTesterThen>; +} + +export interface ReduxTesterThen { + thenDispatchedActionShouldEqual: (...dispatchedAction: AnyAction[]) => ReduxTesterWhen; + thenDispatchedActionPredicateShouldEqual: ( + predicate: (dispatchedActions: AnyAction[]) => boolean + ) => ReduxTesterWhen; +} + +export interface ReduxTesterArguments { + preloadedState?: State; + debug?: boolean; +} + +export const reduxTester = (args?: ReduxTesterArguments): ReduxTesterGiven => { + const dispatchedActions: AnyAction[] = []; + const logActionsMiddleWare: Middleware<{}, Partial> = ( + store: MiddlewareAPI> + ) => (next: Dispatch) => (action: AnyAction) => { + // filter out thunk actions + if (action && typeof action !== 'function') { + dispatchedActions.push(action); + } + + return next(action); + }; + + const preloadedState = args?.preloadedState ?? (({} as unknown) as State); + const debug = args?.debug ?? false; + let store: EnhancedStore | null = null; + + const givenRootReducer = (rootReducer: Reducer): ReduxTesterWhen => { + store = configureStore({ + reducer: rootReducer, + middleware: [logActionsMiddleWare, thunk], + preloadedState, + }); + setStore(store as any); + + return instance; + }; + + const whenActionIsDispatched = ( + action: any, + clearPreviousActions?: boolean + ): ReduxTesterWhen & ReduxTesterThen => { + if (clearPreviousActions) { + dispatchedActions.length = 0; + } + store.dispatch(action); + + return instance; + }; + + const whenAsyncActionIsDispatched = async ( + action: any, + clearPreviousActions?: boolean + ): Promise & ReduxTesterThen> => { + if (clearPreviousActions) { + dispatchedActions.length = 0; + } + + await store.dispatch(action); + + return instance; + }; + + const thenDispatchedActionShouldEqual = (...actions: AnyAction[]): ReduxTesterWhen => { + if (debug) { + console.log('Dispatched Actions', JSON.stringify(dispatchedActions, null, 2)); + } + + if (!actions.length) { + throw new Error('thenDispatchedActionShouldEqual has to be called with at least one action'); + } + + expect(dispatchedActions).toEqual(actions); + return instance; + }; + + const thenDispatchedActionPredicateShouldEqual = ( + predicate: (dispatchedActions: AnyAction[]) => boolean + ): ReduxTesterWhen => { + if (debug) { + console.log('Dispatched Actions', JSON.stringify(dispatchedActions, null, 2)); + } + + expect(predicate(dispatchedActions)).toBe(true); + return instance; + }; + + const instance = { + givenRootReducer, + whenActionIsDispatched, + whenAsyncActionIsDispatched, + thenDispatchedActionShouldEqual, + thenDispatchedActionPredicateShouldEqual, + }; + + return instance; +}; diff --git a/yarn.lock b/yarn.lock index b92155764fc..a0d17f59e87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5866,6 +5866,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== +"@types/uuid@3.4.7": + version "3.4.7" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.7.tgz#51d42247473bc00e38cc8dfaf70d936842a36c03" + integrity sha512-C2j2FWgQkF1ru12SjZJyMaTPxs/f6n90+5G5qNakBxKXjTBc/YTSelHh4Pz1HUDwxFXD9WvpQhOGCDC+/Y4mIQ== + "@types/webpack-dev-server@*": version "3.1.7" resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.1.7.tgz#a3e7a20366e68bc9853c730b56e994634cb78dac" @@ -24975,6 +24980,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"