Commit 9bd6ed88 by Hugo Häggmark Committed by GitHub

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
parent 68d6da77
// 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<Props> {
interface State {
validatonMessage: string;
}
class UnConnectedAlertTab extends PureComponent<Props, State> {
element: any;
component: AngularComponent;
panelCtrl: any;
state: State = {
validatonMessage: '',
};
componentDidMount() {
if (this.shouldLoadAlertTab()) {
this.loadAlertTab();
......@@ -51,8 +62,8 @@ export class AlertTab extends PureComponent<Props> {
}
}
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<Props> {
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<Props> {
this.forceUpdate();
};
switchToQueryTab = () => {
const { changePanelEditorTab } = this.props;
changePanelEditorTab(getPanelEditorTab(PanelEditorTabIds.Queries));
};
renderValidationMessage = () => {
const { validatonMessage } = this.state;
return (
<div
className={css`
width: 508px;
margin: 128px auto;
`}
>
<h2>{validatonMessage}</h2>
<br />
<div className="gf-form-group">
<Button size={'md'} variant={'secondary'} icon="fa fa-arrow-left" onClick={this.switchToQueryTab}>
Go back to Queries
</Button>
</div>
</div>
);
};
render() {
const { alert, transformations } = this.props.panel;
const hasTransformations = transformations && transformations.length;
if (!alert && hasTransformations) {
return (
<EditorTabBody heading="Alert">
<Alert
severity={AppNotificationSeverity.Warning}
title="Transformations are not supported in alert queries"
/>
</EditorTabBody>
);
const { validatonMessage } = this.state;
const hasTransformations = transformations && transformations.length > 0;
if (!alert && validatonMessage) {
return this.renderValidationMessage();
}
const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : [];
......@@ -163,9 +205,20 @@ export class AlertTab extends PureComponent<Props> {
)}
<div ref={element => (this.element = element)} />
{!alert && <EmptyListCTA {...model} />}
{!alert && !validatonMessage && <EmptyListCTA {...model} />}
</>
</EditorTabBody>
);
}
}
export const mapStateToProps = (state: StoreState) => ({});
const mapDispatchToProps = { changePanelEditorTab };
export const AlertTab = hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(UnConnectedAlertTab)
);
......@@ -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
......
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);
});
});
});
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<string> => {
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 '';
};
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;
}
class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
constructor(props: PanelEditorProps) {
super(props);
}
enum PanelEditorTabIds {
Queries = 'queries',
Visualization = 'visualization',
Advanced = 'advanced',
Alert = 'alert',
}
componentDidMount(): void {
this.refreshFromState();
}
interface PanelEditorTab {
id: string;
text: string;
}
componentWillUnmount(): void {
const { panelEditorCleanUp } = this.props;
panelEditorCleanUp();
}
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],
};
};
refreshFromState = (meta?: PanelPluginMeta) => {
const { refreshPanelEditor, plugin } = this.props;
meta = meta || plugin.meta;
export class PanelEditor extends PureComponent<PanelEditorProps> {
constructor(props: PanelEditorProps) {
super(props);
}
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<PanelEditorProps> {
panel={panel}
dashboard={dashboard}
plugin={plugin}
onPluginTypeChange={onPluginTypeChange}
onPluginTypeChange={this.onPluginTypeChange}
angularPanel={angularPanel}
/>
);
......@@ -95,28 +93,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
}
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 (
<div className="panel-editor-container__editor">
......@@ -131,6 +108,20 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
}
}
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;
......
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 }),
]);
});
});
});
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<PanelEditorInitCompleted>(
'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<void> => {
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<void> => {
return async dispatch => {
dispatch(updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }));
};
};
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);
});
});
});
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<PanelEditorState>(initialState)
.addMapper({
filter: panelEditorInitCompleted,
mapper: (state, action): PanelEditorState => {
const { activeTab, tabs } = action.payload;
return {
...state,
activeTab,
tabs,
};
},
})
.addMapper({
filter: panelEditorCleanUp,
mapper: (): PanelEditorState => initialState,
})
.create();
// 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';
......
......@@ -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,
......
......@@ -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,
};
......@@ -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;
......
......@@ -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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment