From 9bd6ed887c7f3bc79336933744acf7b6deb2d0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 23 Sep 2019 05:17:00 -0700 Subject: [PATCH] Alerting: Prevents creating alerts from unsupported queries (#19250) * Refactor: Makes PanelEditor use state and shows validation message on AlerTab * Refactor: Makes validation message nicer looking * Refactor: Changes imports * Refactor: Removes conditional props * Refactor: Changes after feedback from PR review * Refactor: Removes unused action --- public/app/features/alerting/AlertTab.tsx | 95 ++++++++--- public/app/features/alerting/AlertTabCtrl.ts | 15 +- .../getAlertingValidationMessage.test.ts | 148 ++++++++++++++++++ .../alerting/getAlertingValidationMessage.ts | 49 ++++++ .../dashboard/panel_editor/PanelEditor.tsx | 131 ++++++++-------- .../panel_editor/state/actions.test.ts | 127 +++++++++++++++ .../dashboard/panel_editor/state/actions.ts | 54 +++++++ .../panel_editor/state/reducers.test.ts | 35 +++++ .../dashboard/panel_editor/state/reducers.ts | 56 +++++++ .../features/dashboard/state/PanelModel.ts | 2 - .../app/features/dashboard/state/actions.ts | 2 - .../app/features/dashboard/state/reducers.ts | 2 + public/app/types/store.ts | 2 + public/test/core/thunk/thunkTester.ts | 2 +- 14 files changed, 612 insertions(+), 108 deletions(-) create mode 100644 public/app/features/alerting/getAlertingValidationMessage.test.ts create mode 100644 public/app/features/alerting/getAlertingValidationMessage.ts create mode 100644 public/app/features/dashboard/panel_editor/state/actions.test.ts create mode 100644 public/app/features/dashboard/panel_editor/state/actions.ts create mode 100644 public/app/features/dashboard/panel_editor/state/reducers.test.ts create mode 100644 public/app/features/dashboard/panel_editor/state/reducers.ts diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index d1cb27b2558..8e09beda49a 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -1,34 +1,45 @@ -// Libraries import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { css } from 'emotion'; +import { Alert, Button } from '@grafana/ui'; -// Services & Utils -import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; +import { getAlertingValidationMessage } from './getAlertingValidationMessage'; -// Components import { EditorTabBody, EditorToolbarView } from '../dashboard/panel_editor/EditorTabBody'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import StateHistory from './StateHistory'; import 'app/features/alerting/AlertTabCtrl'; -import { Alert } from '@grafana/ui'; -// Types import { DashboardModel } from '../dashboard/state/DashboardModel'; import { PanelModel } from '../dashboard/state/PanelModel'; import { TestRuleResult } from './TestRuleResult'; -import { AppNotificationSeverity } from 'app/types'; +import { AppNotificationSeverity, StoreState } from 'app/types'; +import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers'; +import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions'; interface Props { angularPanel?: AngularComponent; dashboard: DashboardModel; panel: PanelModel; + changePanelEditorTab: typeof changePanelEditorTab; } -export class AlertTab extends PureComponent { +interface State { + validatonMessage: string; +} + +class UnConnectedAlertTab extends PureComponent { element: any; component: AngularComponent; panelCtrl: any; + state: State = { + validatonMessage: '', + }; + componentDidMount() { if (this.shouldLoadAlertTab()) { this.loadAlertTab(); @@ -51,8 +62,8 @@ export class AlertTab extends PureComponent { } } - loadAlertTab() { - const { angularPanel } = this.props; + async loadAlertTab() { + const { angularPanel, panel } = this.props; const scope = angularPanel.getScope(); @@ -71,6 +82,17 @@ export class AlertTab extends PureComponent { const scopeProps = { ctrl: this.panelCtrl }; this.component = loader.load(this.element, scopeProps, template); + + const validatonMessage = await getAlertingValidationMessage( + panel.transformations, + panel.targets, + getDataSourceSrv(), + panel.datasource + ); + + if (validatonMessage) { + this.setState({ validatonMessage }); + } } stateHistory = (): EditorToolbarView => { @@ -128,19 +150,39 @@ export class AlertTab extends PureComponent { this.forceUpdate(); }; + switchToQueryTab = () => { + const { changePanelEditorTab } = this.props; + changePanelEditorTab(getPanelEditorTab(PanelEditorTabIds.Queries)); + }; + + renderValidationMessage = () => { + const { validatonMessage } = this.state; + + return ( +
+

{validatonMessage}

+
+
+ +
+
+ ); + }; + render() { const { alert, transformations } = this.props.panel; - const hasTransformations = transformations && transformations.length; + const { validatonMessage } = this.state; + const hasTransformations = transformations && transformations.length > 0; - if (!alert && hasTransformations) { - return ( - - - - ); + if (!alert && validatonMessage) { + return this.renderValidationMessage(); } const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : []; @@ -163,9 +205,20 @@ export class AlertTab extends PureComponent { )}
(this.element = element)} /> - {!alert && } + {!alert && !validatonMessage && } ); } } + +export const mapStateToProps = (state: StoreState) => ({}); + +const mapDispatchToProps = { changePanelEditorTab }; + +export const AlertTab = hot(module)( + connect( + mapStateToProps, + mapDispatchToProps + )(UnConnectedAlertTab) +); diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts index dfec502323f..de82193b16b 100644 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -10,6 +10,7 @@ import { DashboardSrv } from '../dashboard/services/DashboardSrv'; import DatasourceSrv from '../plugins/datasource_srv'; import { DataQuery } from '@grafana/ui/src/types/datasource'; import { PanelModel } from 'app/features/dashboard/state'; +import { getDefaultCondition } from './getAlertingValidationMessage'; export class AlertTabCtrl { panel: PanelModel; @@ -179,7 +180,7 @@ export class AlertTabCtrl { alert.conditions = alert.conditions || []; if (alert.conditions.length === 0) { - alert.conditions.push(this.buildDefaultCondition()); + alert.conditions.push(getDefaultCondition()); } alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues; @@ -241,16 +242,6 @@ export class AlertTabCtrl { } } - buildDefaultCondition() { - return { - type: 'query', - query: { params: ['A', '5m', 'now'] }, - reducer: { type: 'avg', params: [] as any[] }, - evaluator: { type: 'gt', params: [null] as any[] }, - operator: { type: 'and' }, - }; - } - validateModel() { if (!this.alert) { return; @@ -348,7 +339,7 @@ export class AlertTabCtrl { } addCondition(type: string) { - const condition = this.buildDefaultCondition(); + const condition = getDefaultCondition(); // add to persited model this.alert.conditions.push(condition); // add to view model diff --git a/public/app/features/alerting/getAlertingValidationMessage.test.ts b/public/app/features/alerting/getAlertingValidationMessage.test.ts new file mode 100644 index 00000000000..723754ce257 --- /dev/null +++ b/public/app/features/alerting/getAlertingValidationMessage.test.ts @@ -0,0 +1,148 @@ +import { DataSourceSrv } from '@grafana/runtime'; +import { DataSourceApi, PluginMeta } from '@grafana/ui'; +import { DataTransformerConfig } from '@grafana/data'; + +import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types'; +import { getAlertingValidationMessage } from './getAlertingValidationMessage'; + +describe('getAlertingValidationMessage', () => { + describe('when called with some targets containing template variables', () => { + it('then it should return false', async () => { + let call = 0; + const datasource: DataSourceApi = ({ + meta: ({ alerting: true } as any) as PluginMeta, + targetContainsTemplate: () => { + if (call === 0) { + call++; + return true; + } + return false; + }, + name: 'some name', + } as any) as DataSourceApi; + const getMock = jest.fn().mockResolvedValue(datasource); + const datasourceSrv: DataSourceSrv = { + get: getMock, + }; + const targets: ElasticsearchQuery[] = [ + { refId: 'A', query: '@hostname:$hostname', isLogsQuery: false }, + { refId: 'B', query: '@instance:instance', isLogsQuery: false }, + ]; + const transformations: DataTransformerConfig[] = []; + + const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name); + + expect(result).toBe(''); + expect(getMock).toHaveBeenCalledTimes(2); + expect(getMock).toHaveBeenCalledWith(datasource.name); + }); + }); + + describe('when called with some targets using a datasource that does not support alerting', () => { + it('then it should return false', async () => { + const alertingDatasource: DataSourceApi = ({ + meta: ({ alerting: true } as any) as PluginMeta, + targetContainsTemplate: () => false, + name: 'alertingDatasource', + } as any) as DataSourceApi; + const datasource: DataSourceApi = ({ + meta: ({ alerting: false } as any) as PluginMeta, + targetContainsTemplate: () => false, + name: 'datasource', + } as any) as DataSourceApi; + + const datasourceSrv: DataSourceSrv = { + get: (name: string) => { + if (name === datasource.name) { + return Promise.resolve(datasource); + } + + return Promise.resolve(alertingDatasource); + }, + }; + const targets: any[] = [ + { refId: 'A', query: 'some query', datasource: 'alertingDatasource' }, + { refId: 'B', query: 'some query', datasource: 'datasource' }, + ]; + const transformations: DataTransformerConfig[] = []; + + const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name); + + expect(result).toBe(''); + }); + }); + + describe('when called with all targets containing template variables', () => { + it('then it should return false', async () => { + const datasource: DataSourceApi = ({ + meta: ({ alerting: true } as any) as PluginMeta, + targetContainsTemplate: () => true, + name: 'some name', + } as any) as DataSourceApi; + const getMock = jest.fn().mockResolvedValue(datasource); + const datasourceSrv: DataSourceSrv = { + get: getMock, + }; + const targets: ElasticsearchQuery[] = [ + { refId: 'A', query: '@hostname:$hostname', isLogsQuery: false }, + { refId: 'B', query: '@instance:$instance', isLogsQuery: false }, + ]; + const transformations: DataTransformerConfig[] = []; + + const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name); + + expect(result).toBe('Template variables are not supported in alert queries'); + expect(getMock).toHaveBeenCalledTimes(2); + expect(getMock).toHaveBeenCalledWith(datasource.name); + }); + }); + + describe('when called with all targets using a datasource that does not support alerting', () => { + it('then it should return false', async () => { + const datasource: DataSourceApi = ({ + meta: ({ alerting: false } as any) as PluginMeta, + targetContainsTemplate: () => false, + name: 'some name', + } as any) as DataSourceApi; + const getMock = jest.fn().mockResolvedValue(datasource); + const datasourceSrv: DataSourceSrv = { + get: getMock, + }; + const targets: ElasticsearchQuery[] = [ + { refId: 'A', query: '@hostname:hostname', isLogsQuery: false }, + { refId: 'B', query: '@instance:instance', isLogsQuery: false }, + ]; + const transformations: DataTransformerConfig[] = []; + + const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name); + + expect(result).toBe('The datasource does not support alerting queries'); + expect(getMock).toHaveBeenCalledTimes(2); + expect(getMock).toHaveBeenCalledWith(datasource.name); + }); + }); + + describe('when called with transformations', () => { + it('then it should return false', async () => { + const datasource: DataSourceApi = ({ + meta: ({ alerting: true } as any) as PluginMeta, + targetContainsTemplate: () => false, + name: 'some name', + } as any) as DataSourceApi; + const getMock = jest.fn().mockResolvedValue(datasource); + const datasourceSrv: DataSourceSrv = { + get: getMock, + }; + const targets: ElasticsearchQuery[] = [ + { refId: 'A', query: '@hostname:hostname', isLogsQuery: false }, + { refId: 'B', query: '@instance:instance', isLogsQuery: false }, + ]; + const transformations: DataTransformerConfig[] = [{ id: 'A', options: null }]; + + const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name); + + expect(result).toBe('Transformations are not supported in alert queries'); + expect(getMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/public/app/features/alerting/getAlertingValidationMessage.ts b/public/app/features/alerting/getAlertingValidationMessage.ts new file mode 100644 index 00000000000..4add3bd19c5 --- /dev/null +++ b/public/app/features/alerting/getAlertingValidationMessage.ts @@ -0,0 +1,49 @@ +import { DataQuery } from '@grafana/ui'; +import { DataSourceSrv } from '@grafana/runtime'; +import { DataTransformerConfig } from '@grafana/data'; + +export const getDefaultCondition = () => ({ + type: 'query', + query: { params: ['A', '5m', 'now'] }, + reducer: { type: 'avg', params: [] as any[] }, + evaluator: { type: 'gt', params: [null] as any[] }, + operator: { type: 'and' }, +}); + +export const getAlertingValidationMessage = async ( + transformations: DataTransformerConfig[], + targets: DataQuery[], + datasourceSrv: DataSourceSrv, + datasourceName: string +): Promise => { + if (targets.length === 0) { + return 'Could not find any metric queries'; + } + + if (transformations && transformations.length) { + return 'Transformations are not supported in alert queries'; + } + + let alertingNotSupported = 0; + let templateVariablesNotSupported = 0; + + for (const target of targets) { + const dsName = target.datasource || datasourceName; + const ds = await datasourceSrv.get(dsName); + if (!ds.meta.alerting) { + alertingNotSupported++; + } else if (ds.targetContainsTemplate && ds.targetContainsTemplate(target)) { + templateVariablesNotSupported++; + } + } + + if (alertingNotSupported === targets.length) { + return 'The datasource does not support alerting queries'; + } + + if (templateVariablesNotSupported === targets.length) { + return 'Template variables are not supported in alert queries'; + } + + return ''; +}; diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx index 5a27d9e1e63..539810d4ad3 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -1,19 +1,19 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui'; +import { AngularComponent, config } from '@grafana/runtime'; import { QueriesTab } from './QueriesTab'; import VisualizationTab from './VisualizationTab'; import { GeneralTab } from './GeneralTab'; import { AlertTab } from '../../alerting/AlertTab'; - -import config from 'app/core/config'; -import { store } from 'app/store/store'; -import { updateLocation } from 'app/core/actions'; -import { AngularComponent } from '@grafana/runtime'; - import { PanelModel } from '../state/PanelModel'; import { DashboardModel } from '../state/DashboardModel'; -import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui'; +import { StoreState } from '../../../types'; +import { PanelEditorTabIds, PanelEditorTab } from './state/reducers'; +import { refreshPanelEditor, changePanelEditorTab, panelEditorCleanUp } from './state/actions'; interface PanelEditorProps { panel: PanelModel; @@ -21,56 +21,54 @@ interface PanelEditorProps { plugin: PanelPlugin; angularPanel?: AngularComponent; onPluginTypeChange: (newType: PanelPluginMeta) => void; + activeTab: PanelEditorTabIds; + tabs: PanelEditorTab[]; + refreshPanelEditor: typeof refreshPanelEditor; + panelEditorCleanUp: typeof panelEditorCleanUp; + changePanelEditorTab: typeof changePanelEditorTab; } -interface PanelEditorTab { - id: string; - text: string; -} - -enum PanelEditorTabIds { - Queries = 'queries', - Visualization = 'visualization', - Advanced = 'advanced', - Alert = 'alert', -} - -interface PanelEditorTab { - id: string; - text: string; -} - -const panelEditorTabTexts = { - [PanelEditorTabIds.Queries]: 'Queries', - [PanelEditorTabIds.Visualization]: 'Visualization', - [PanelEditorTabIds.Advanced]: 'General', - [PanelEditorTabIds.Alert]: 'Alert', -}; - -const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => { - return { - id: tabId, - text: panelEditorTabTexts[tabId], - }; -}; - -export class PanelEditor extends PureComponent { +class UnConnectedPanelEditor extends PureComponent { constructor(props: PanelEditorProps) { super(props); } + componentDidMount(): void { + this.refreshFromState(); + } + + componentWillUnmount(): void { + const { panelEditorCleanUp } = this.props; + panelEditorCleanUp(); + } + + refreshFromState = (meta?: PanelPluginMeta) => { + const { refreshPanelEditor, plugin } = this.props; + meta = meta || plugin.meta; + + refreshPanelEditor({ + hasQueriesTab: !meta.skipDataQuery, + usesGraphPlugin: meta.id === 'graph', + alertingEnabled: config.alertingEnabled, + }); + }; + onChangeTab = (tab: PanelEditorTab) => { - store.dispatch( - updateLocation({ - query: { tab: tab.id, openVizPicker: null }, - partial: true, - }) - ); - this.forceUpdate(); + const { changePanelEditorTab } = this.props; + // Angular Query Components can potentially refresh the PanelModel + // onBlur so this makes sure we change tab after that + setTimeout(() => changePanelEditorTab(tab), 10); + }; + + onPluginTypeChange = (newType: PanelPluginMeta) => { + const { onPluginTypeChange } = this.props; + onPluginTypeChange(newType); + + this.refreshFromState(newType); }; renderCurrentTab(activeTab: string) { - const { panel, dashboard, onPluginTypeChange, plugin, angularPanel } = this.props; + const { panel, dashboard, plugin, angularPanel } = this.props; switch (activeTab) { case 'advanced': @@ -85,7 +83,7 @@ export class PanelEditor extends PureComponent { panel={panel} dashboard={dashboard} plugin={plugin} - onPluginTypeChange={onPluginTypeChange} + onPluginTypeChange={this.onPluginTypeChange} angularPanel={angularPanel} /> ); @@ -95,28 +93,7 @@ export class PanelEditor extends PureComponent { } render() { - const { plugin } = this.props; - let activeTab: PanelEditorTabIds = store.getState().location.query.tab || PanelEditorTabIds.Queries; - - const tabs: PanelEditorTab[] = [ - getPanelEditorTab(PanelEditorTabIds.Queries), - getPanelEditorTab(PanelEditorTabIds.Visualization), - getPanelEditorTab(PanelEditorTabIds.Advanced), - ]; - - // handle panels that do not have queries tab - if (plugin.meta.skipDataQuery) { - // remove queries tab - tabs.shift(); - // switch tab - if (activeTab === PanelEditorTabIds.Queries) { - activeTab = PanelEditorTabIds.Visualization; - } - } - - if (config.alertingEnabled && plugin.meta.id === 'graph') { - tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert)); - } + const { activeTab, tabs } = this.props; return (
@@ -131,6 +108,20 @@ export class PanelEditor extends PureComponent { } } +export const mapStateToProps = (state: StoreState) => ({ + activeTab: state.location.query.tab || PanelEditorTabIds.Queries, + tabs: state.panelEditor.tabs, +}); + +const mapDispatchToProps = { refreshPanelEditor, panelEditorCleanUp, changePanelEditorTab }; + +export const PanelEditor = hot(module)( + connect( + mapStateToProps, + mapDispatchToProps + )(UnConnectedPanelEditor) +); + interface TabItemParams { tab: PanelEditorTab; activeTab: string; diff --git a/public/app/features/dashboard/panel_editor/state/actions.test.ts b/public/app/features/dashboard/panel_editor/state/actions.test.ts new file mode 100644 index 00000000000..73b3a58181f --- /dev/null +++ b/public/app/features/dashboard/panel_editor/state/actions.test.ts @@ -0,0 +1,127 @@ +import { thunkTester } from '../../../../../test/core/thunk/thunkTester'; +import { initialState, getPanelEditorTab, PanelEditorTabIds } from './reducers'; +import { refreshPanelEditor, panelEditorInitCompleted, changePanelEditorTab } from './actions'; +import { updateLocation } from '../../../../core/actions'; + +describe('refreshPanelEditor', () => { + describe('when called and there is no activeTab in state', () => { + it('then the dispatched action should default the activeTab to PanelEditorTabIds.Queries', async () => { + const activeTab = PanelEditorTabIds.Queries; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + getPanelEditorTab(PanelEditorTabIds.Alert), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab: null } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); + + describe('when called and there is already an activeTab in state', () => { + it('then the dispatched action should include activeTab from state', async () => { + const activeTab = PanelEditorTabIds.Visualization; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + getPanelEditorTab(PanelEditorTabIds.Alert), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); + + describe('when called and plugin has no queries tab', () => { + it('then the dispatched action should not include Queries tab and default the activeTab to PanelEditorTabIds.Visualization', async () => { + const activeTab = PanelEditorTabIds.Visualization; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + getPanelEditorTab(PanelEditorTabIds.Alert), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: false, alertingEnabled: true, usesGraphPlugin: true }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); + + describe('when called and alerting is enabled and the visualization is the graph plugin', () => { + it('then the dispatched action should include the alert tab', async () => { + const activeTab = PanelEditorTabIds.Queries; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + getPanelEditorTab(PanelEditorTabIds.Alert), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); + + describe('when called and alerting is not enabled', () => { + it('then the dispatched action should not include the alert tab', async () => { + const activeTab = PanelEditorTabIds.Queries; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: false, usesGraphPlugin: true }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); + + describe('when called and the visualization is not the graph plugin', () => { + it('then the dispatched action should not include the alert tab', async () => { + const activeTab = PanelEditorTabIds.Queries; + const tabs = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + ]; + const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } }) + .givenThunk(refreshPanelEditor) + .whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: false }); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs })); + }); + }); +}); + +describe('changePanelEditorTab', () => { + describe('when called', () => { + it('then it should dispatch correct actions', async () => { + const activeTab = getPanelEditorTab(PanelEditorTabIds.Visualization); + const dispatchedActions = await thunkTester({}) + .givenThunk(changePanelEditorTab) + .whenThunkIsDispatched(activeTab); + + expect(dispatchedActions.length).toBe(1); + expect(dispatchedActions).toEqual([ + updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }), + ]); + }); + }); +}); diff --git a/public/app/features/dashboard/panel_editor/state/actions.ts b/public/app/features/dashboard/panel_editor/state/actions.ts new file mode 100644 index 00000000000..8e15749bf6b --- /dev/null +++ b/public/app/features/dashboard/panel_editor/state/actions.ts @@ -0,0 +1,54 @@ +import { actionCreatorFactory, noPayloadActionCreatorFactory } from '../../../../core/redux'; +import { PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers'; +import { ThunkResult } from '../../../../types'; +import { updateLocation } from '../../../../core/actions'; + +export interface PanelEditorInitCompleted { + activeTab: PanelEditorTabIds; + tabs: PanelEditorTab[]; +} + +export const panelEditorInitCompleted = actionCreatorFactory( + 'PANEL_EDITOR_INIT_COMPLETED' +).create(); + +export const panelEditorCleanUp = noPayloadActionCreatorFactory('PANEL_EDITOR_CLEAN_UP').create(); + +export const refreshPanelEditor = (props: { + hasQueriesTab?: boolean; + usesGraphPlugin?: boolean; + alertingEnabled?: boolean; +}): ThunkResult => { + return async (dispatch, getState) => { + let activeTab = getState().panelEditor.activeTab || PanelEditorTabIds.Queries; + const { hasQueriesTab, usesGraphPlugin, alertingEnabled } = props; + + const tabs: PanelEditorTab[] = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + ]; + + // handle panels that do not have queries tab + if (!hasQueriesTab) { + // remove queries tab + tabs.shift(); + // switch tab + if (activeTab === PanelEditorTabIds.Queries) { + activeTab = PanelEditorTabIds.Visualization; + } + } + + if (alertingEnabled && usesGraphPlugin) { + tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert)); + } + + dispatch(panelEditorInitCompleted({ activeTab, tabs })); + }; +}; + +export const changePanelEditorTab = (activeTab: PanelEditorTab): ThunkResult => { + return async dispatch => { + dispatch(updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true })); + }; +}; diff --git a/public/app/features/dashboard/panel_editor/state/reducers.test.ts b/public/app/features/dashboard/panel_editor/state/reducers.test.ts new file mode 100644 index 00000000000..b9f2196eb28 --- /dev/null +++ b/public/app/features/dashboard/panel_editor/state/reducers.test.ts @@ -0,0 +1,35 @@ +import { reducerTester } from '../../../../../test/core/redux/reducerTester'; +import { initialState, panelEditorReducer, PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers'; +import { panelEditorInitCompleted, panelEditorCleanUp } from './actions'; + +describe('panelEditorReducer', () => { + describe('when panelEditorInitCompleted is dispatched', () => { + it('then state should be correct', () => { + const activeTab = PanelEditorTabIds.Alert; + const tabs: PanelEditorTab[] = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + ]; + reducerTester() + .givenReducer(panelEditorReducer, initialState) + .whenActionIsDispatched(panelEditorInitCompleted({ activeTab, tabs })) + .thenStateShouldEqual({ activeTab, tabs }); + }); + }); + + describe('when panelEditorCleanUp is dispatched', () => { + it('then state should be intialState', () => { + const activeTab = PanelEditorTabIds.Alert; + const tabs: PanelEditorTab[] = [ + getPanelEditorTab(PanelEditorTabIds.Queries), + getPanelEditorTab(PanelEditorTabIds.Visualization), + getPanelEditorTab(PanelEditorTabIds.Advanced), + ]; + reducerTester() + .givenReducer(panelEditorReducer, { activeTab, tabs }) + .whenActionIsDispatched(panelEditorCleanUp()) + .thenStateShouldEqual(initialState); + }); + }); +}); diff --git a/public/app/features/dashboard/panel_editor/state/reducers.ts b/public/app/features/dashboard/panel_editor/state/reducers.ts new file mode 100644 index 00000000000..746525c8356 --- /dev/null +++ b/public/app/features/dashboard/panel_editor/state/reducers.ts @@ -0,0 +1,56 @@ +import { reducerFactory } from '../../../../core/redux'; +import { panelEditorCleanUp, panelEditorInitCompleted } from './actions'; + +export interface PanelEditorTab { + id: string; + text: string; +} + +export enum PanelEditorTabIds { + Queries = 'queries', + Visualization = 'visualization', + Advanced = 'advanced', + Alert = 'alert', +} + +export const panelEditorTabTexts = { + [PanelEditorTabIds.Queries]: 'Queries', + [PanelEditorTabIds.Visualization]: 'Visualization', + [PanelEditorTabIds.Advanced]: 'General', + [PanelEditorTabIds.Alert]: 'Alert', +}; + +export const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => { + return { + id: tabId, + text: panelEditorTabTexts[tabId], + }; +}; + +export interface PanelEditorState { + activeTab: PanelEditorTabIds; + tabs: PanelEditorTab[]; +} + +export const initialState: PanelEditorState = { + activeTab: null, + tabs: [], +}; + +export const panelEditorReducer = reducerFactory(initialState) + .addMapper({ + filter: panelEditorInitCompleted, + mapper: (state, action): PanelEditorState => { + const { activeTab, tabs } = action.payload; + return { + ...state, + activeTab, + tabs, + }; + }, + }) + .addMapper({ + filter: panelEditorCleanUp, + mapper: (): PanelEditorState => initialState, + }) + .create(); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 6648ba6b342..093447d53d5 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -1,10 +1,8 @@ // Libraries import _ from 'lodash'; - // Utils import { Emitter } from 'app/core/utils/emitter'; import { getNextRefIdChar } from 'app/core/utils/query'; - // Types import { DataQuery, DataQueryResponseData, PanelPlugin } from '@grafana/ui'; import { DataLink, DataTransformerConfig, ScopedVars } from '@grafana/data'; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 23828323977..ec39e557360 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -2,11 +2,9 @@ import { getBackendSrv } from '@grafana/runtime'; import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux'; import { createSuccessNotification } from 'app/core/copy/appNotification'; - // Actions import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; - // Types import { ThunkResult, diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 8429e210253..a65e85c3011 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -11,6 +11,7 @@ import { import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; import { DashboardModel } from './DashboardModel'; +import { panelEditorReducer } from '../panel_editor/state/reducers'; export const initialState: DashboardState = { initPhase: DashboardInitPhase.NotStarted, @@ -87,4 +88,5 @@ export const dashboardReducer = reducerFactory(initialState) export default { dashboard: dashboardReducer, + panelEditor: panelEditorReducer, }; diff --git a/public/app/types/store.ts b/public/app/types/store.ts index d39e77f46f1..e0973704d41 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -15,6 +15,7 @@ import { PluginsState } from './plugins'; import { NavIndex } from '@grafana/data'; import { ApplicationState } from './application'; import { LdapState, LdapUserState } from './ldap'; +import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers'; export interface StoreState { navIndex: NavIndex; @@ -24,6 +25,7 @@ export interface StoreState { team: TeamState; folder: FolderState; dashboard: DashboardState; + panelEditor: PanelEditorState; dataSources: DataSourcesState; explore: ExploreState; users: UsersState; diff --git a/public/test/core/thunk/thunkTester.ts b/public/test/core/thunk/thunkTester.ts index 3972ddc8ee0..8b199e7ab81 100644 --- a/public/test/core/thunk/thunkTester.ts +++ b/public/test/core/thunk/thunkTester.ts @@ -29,7 +29,7 @@ export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => { dispatchedActions = store.getActions(); if (debug) { - console.log('resultingActions:', dispatchedActions); + console.log('resultingActions:', JSON.stringify(dispatchedActions, null, 2)); } return dispatchedActions;