Commit c002a394 by Torkel Ödegaard Committed by GitHub

NewPanelEditor: Angular panel options, and angular component state to redux major change (#22448)

* NewPanelEdit: Added angular options to new panel editor and started looking and angular component state

* Moved angular component state to redux

* Close to working 100%

* Think everything is working

* AlertTab: Alert tab now gets angularComponent from redux

* Fixed panel menu access to angular panel component

* Added new tests

* Fixed unit test

* Fixed strict null errors

* Fixed typescript issues

* fixed issues
parent 60dbf728
...@@ -176,37 +176,50 @@ function buildFormats() { ...@@ -176,37 +176,50 @@ function buildFormats() {
hasBuiltIndex = true; hasBuiltIndex = true;
} }
export function getValueFormat(id: string): ValueFormatter { export function getValueFormat(id?: string | null): ValueFormatter {
if (!id) {
return toFixedUnit('');
}
if (!hasBuiltIndex) { if (!hasBuiltIndex) {
buildFormats(); buildFormats();
} }
const fmt = index[id]; const fmt = index[id];
if (!fmt && id) { if (!fmt && id) {
const idx = id.indexOf(':'); const idx = id.indexOf(':');
if (idx > 0) { if (idx > 0) {
const key = id.substring(0, idx); const key = id.substring(0, idx);
const sub = id.substring(idx + 1); const sub = id.substring(idx + 1);
if (key === 'prefix') { if (key === 'prefix') {
return toFixedUnit(sub, true); return toFixedUnit(sub, true);
} }
if (key === 'time') { if (key === 'time') {
return toDateTimeValueFormatter(sub); return toDateTimeValueFormatter(sub);
} }
if (key === 'si') { if (key === 'si') {
const offset = getOffsetFromSIPrefix(sub.charAt(0)); const offset = getOffsetFromSIPrefix(sub.charAt(0));
const unit = offset === 0 ? sub : sub.substring(1); const unit = offset === 0 ? sub : sub.substring(1);
return decimalSIPrefix(unit, offset); return decimalSIPrefix(unit, offset);
} }
if (key === 'count') { if (key === 'count') {
return simpleCountUnit(sub); return simpleCountUnit(sub);
} }
if (key === 'currency') { if (key === 'currency') {
return currency(sub); return currency(sub);
} }
} }
return toFixedUnit(id); return toFixedUnit(id);
} }
return fmt; return fmt;
} }
......
...@@ -11,7 +11,7 @@ import { DataLinkEditor } from './DataLinkEditor'; ...@@ -11,7 +11,7 @@ import { DataLinkEditor } from './DataLinkEditor';
import { useTheme } from '../../themes/ThemeContext'; import { useTheme } from '../../themes/ThemeContext';
interface DataLinksEditorProps { interface DataLinksEditorProps {
value: DataLink[]; value?: DataLink[];
onChange: (links: DataLink[], callback?: () => void) => void; onChange: (links: DataLink[], callback?: () => void) => void;
suggestions: VariableSuggestion[]; suggestions: VariableSuggestion[];
maxLinks?: number; maxLinks?: number;
...@@ -25,7 +25,8 @@ export const enableDatalinksPrismSyntax = () => { ...@@ -25,7 +25,8 @@ export const enableDatalinksPrismSyntax = () => {
}; };
}; };
export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, onChange, suggestions, maxLinks }) => { export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(
({ value = [], onChange, suggestions, maxLinks }) => {
const theme = useTheme(); const theme = useTheme();
enableDatalinksPrismSyntax(); enableDatalinksPrismSyntax();
...@@ -78,6 +79,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on ...@@ -78,6 +79,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
)} )}
</> </>
); );
}); }
);
DataLinksEditor.displayName = 'DataLinksEditor'; DataLinksEditor.displayName = 'DataLinksEditor';
...@@ -105,10 +105,10 @@ export const BarGaugeCell = () => { ...@@ -105,10 +105,10 @@ export const BarGaugeCell = () => {
{ {
matcher: { id: FieldMatcherID.byName, options: 'Progress' }, matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [ properties: [
{ path: 'custom.width', value: '200' }, { prop: 'width', value: '200', custom: true },
{ path: 'custom.displayMode', value: 'gradient-gauge' }, { prop: 'displayMode', value: 'gradient-gauge', custom: true },
{ path: 'min', value: '0' }, { prop: 'min', value: '0' },
{ path: 'max', value: '100' }, { prop: 'max', value: '100' },
], ],
}, },
]); ]);
...@@ -141,11 +141,11 @@ export const ColoredCells = () => { ...@@ -141,11 +141,11 @@ export const ColoredCells = () => {
{ {
matcher: { id: FieldMatcherID.byName, options: 'Progress' }, matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [ properties: [
{ path: 'custom.width', value: '80' }, { prop: 'width', value: '80', custom: true },
{ path: 'custom.displayMode', value: 'color-background' }, { prop: 'displayMode', value: 'color-background', custom: true },
{ path: 'min', value: '0' }, { prop: 'min', value: '0' },
{ path: 'max', value: '100' }, { prop: 'max', value: '100' },
{ path: 'thresholds', value: defaultThresholds }, { prop: 'thresholds', value: defaultThresholds },
], ],
}, },
]); ]);
......
...@@ -6,7 +6,7 @@ import { action } from '@storybook/addon-actions'; ...@@ -6,7 +6,7 @@ import { action } from '@storybook/addon-actions';
import { DataFrame } from '@grafana/data'; import { DataFrame } from '@grafana/data';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
const TableInputStories = storiesOf('General/Table/Input', module); const TableInputStories = storiesOf('General/Experimental/TableInputCSV', module);
TableInputStories.addDecorator(withCenteredStory); TableInputStories.addDecorator(withCenteredStory);
......
...@@ -6,21 +6,10 @@ import { ThemeContext } from '../../themes/ThemeContext'; ...@@ -6,21 +6,10 @@ import { ThemeContext } from '../../themes/ThemeContext';
import { Input } from '../Input/Input'; import { Input } from '../Input/Input';
import { ColorPicker } from '../ColorPicker/ColorPicker'; import { ColorPicker } from '../ColorPicker/ColorPicker';
import { css } from 'emotion'; import { css } from 'emotion';
import Select from '../Select/Select';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
const modes: Array<SelectableValue<ThresholdsMode>> = [
{ value: ThresholdsMode.Absolute, label: 'Absolute', description: 'Pick thresholds based on the absolute values' },
{
value: ThresholdsMode.Percentage,
label: 'Percentage',
description: 'Pick threshold based on the percent between min/max',
},
];
export interface Props { export interface Props {
showAlphaUI?: boolean; thresholds?: ThresholdsConfig;
thresholds: ThresholdsConfig;
onChange: (thresholds: ThresholdsConfig) => void; onChange: (thresholds: ThresholdsConfig) => void;
} }
...@@ -34,25 +23,11 @@ interface ThresholdWithKey extends Threshold { ...@@ -34,25 +23,11 @@ interface ThresholdWithKey extends Threshold {
let counter = 100; let counter = 100;
function toThresholdsWithKey(steps?: Threshold[]): ThresholdWithKey[] {
if (!steps || steps.length === 0) {
steps = [{ value: -Infinity, color: 'green' }];
}
return steps.map(t => {
return {
color: t.color,
value: t.value === null ? -Infinity : t.value,
key: counter++,
};
});
}
export class ThresholdsEditor extends PureComponent<Props, State> { export class ThresholdsEditor extends PureComponent<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
const steps = toThresholdsWithKey(props.thresholds!.steps); const steps = toThresholdsWithKey(props.thresholds);
steps[0].value = -Infinity; steps[0].value = -Infinity;
this.state = { steps }; this.state = { steps };
...@@ -165,14 +140,16 @@ export class ThresholdsEditor extends PureComponent<Props, State> { ...@@ -165,14 +140,16 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
onModeChanged = (item: SelectableValue<ThresholdsMode>) => { onModeChanged = (item: SelectableValue<ThresholdsMode>) => {
if (item.value) { if (item.value) {
this.props.onChange({ this.props.onChange({
...this.props.thresholds, ...getThresholdOrDefault(this.props.thresholds),
mode: item.value, mode: item.value,
}); });
} }
}; };
renderInput = (threshold: ThresholdWithKey) => { renderInput = (threshold: ThresholdWithKey) => {
const isPercent = this.props.thresholds.mode === ThresholdsMode.Percentage; const config = getThresholdOrDefault(this.props.thresholds);
const isPercent = config.mode === ThresholdsMode.Percentage;
return ( return (
<div className="thresholds-row-input-inner"> <div className="thresholds-row-input-inner">
<span className="thresholds-row-input-inner-arrow" /> <span className="thresholds-row-input-inner-arrow" />
...@@ -218,7 +195,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> { ...@@ -218,7 +195,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
render() { render() {
const { steps } = this.state; const { steps } = this.state;
const t = this.props.thresholds;
return ( return (
<PanelOptionsGroup title="Thresholds"> <PanelOptionsGroup title="Thresholds">
<ThemeContext.Consumer> <ThemeContext.Consumer>
...@@ -243,12 +220,6 @@ export class ThresholdsEditor extends PureComponent<Props, State> { ...@@ -243,12 +220,6 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
); );
})} })}
</div> </div>
{this.props.showAlphaUI && (
<div>
<Select options={modes} value={modes.filter(m => m.value === t.mode)} onChange={this.onModeChanged} />
</div>
)}
</> </>
)} )}
</ThemeContext.Consumer> </ThemeContext.Consumer>
...@@ -257,8 +228,14 @@ export class ThresholdsEditor extends PureComponent<Props, State> { ...@@ -257,8 +228,14 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
} }
} }
export function thresholdsWithoutKey(thresholds: ThresholdsConfig, steps: ThresholdWithKey[]): ThresholdsConfig { export function thresholdsWithoutKey(
thresholds: ThresholdsConfig | undefined,
steps: ThresholdWithKey[]
): ThresholdsConfig {
thresholds = getThresholdOrDefault(thresholds);
const mode = thresholds.mode ?? ThresholdsMode.Absolute; const mode = thresholds.mode ?? ThresholdsMode.Absolute;
return { return {
mode, mode,
steps: steps.map(t => { steps: steps.map(t => {
...@@ -267,3 +244,25 @@ export function thresholdsWithoutKey(thresholds: ThresholdsConfig, steps: Thresh ...@@ -267,3 +244,25 @@ export function thresholdsWithoutKey(thresholds: ThresholdsConfig, steps: Thresh
}), }),
}; };
} }
function getThresholdOrDefault(thresholds?: ThresholdsConfig): ThresholdsConfig {
return thresholds ?? { steps: [], mode: ThresholdsMode.Absolute };
}
function toThresholdsWithKey(thresholds?: ThresholdsConfig): ThresholdWithKey[] {
thresholds = getThresholdOrDefault(thresholds);
let steps: Threshold[] = thresholds.steps || [];
if (thresholds.steps && thresholds.steps.length === 0) {
steps = [{ value: -Infinity, color: 'green' }];
}
return steps.map(t => {
return {
color: t.color,
value: t.value === null ? -Infinity : t.value,
key: counter++,
};
});
}
import React, { ChangeEvent } from 'react'; import React, { ChangeEvent } from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { GrafanaThemeType, ThresholdsMode } from '@grafana/data'; import { ThresholdsMode } from '@grafana/data';
import { ThresholdsEditor, Props, thresholdsWithoutKey } from './ThresholdsEditor'; import { ThresholdsEditor, Props, thresholdsWithoutKey } from './ThresholdsEditor';
import { colors } from '../../utils'; import { colors } from '../../utils';
import { mockThemeContext } from '../../themes/ThemeContext'; import { mockThemeContext } from '../../themes/ThemeContext';
......
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader'; import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
import { connect } from 'react-redux';
import { css } from 'emotion'; import { css } from 'emotion';
import { Alert, Button } from '@grafana/ui'; import { Alert, Button } from '@grafana/ui';
...@@ -14,19 +13,28 @@ import StateHistory from './StateHistory'; ...@@ -14,19 +13,28 @@ import StateHistory from './StateHistory';
import 'app/features/alerting/AlertTabCtrl'; import 'app/features/alerting/AlertTabCtrl';
import { DashboardModel } from '../dashboard/state/DashboardModel'; import { DashboardModel } from '../dashboard/state/DashboardModel';
import { PanelModel, angularPanelUpdated } from '../dashboard/state/PanelModel'; import { PanelModel } from '../dashboard/state/PanelModel';
import { TestRuleResult } from './TestRuleResult'; import { TestRuleResult } from './TestRuleResult';
import { AppNotificationSeverity, StoreState } from 'app/types'; import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers'; import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers';
import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions'; import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
interface Props { interface OwnProps {
dashboard: DashboardModel; dashboard: DashboardModel;
panel: PanelModel; panel: PanelModel;
}
interface ConnectedProps {
angularPanelComponent: AngularComponent;
}
interface DispatchProps {
changePanelEditorTab: typeof changePanelEditorTab; changePanelEditorTab: typeof changePanelEditorTab;
} }
export type Props = OwnProps & ConnectedProps & DispatchProps;
interface State { interface State {
validatonMessage: string; validatonMessage: string;
} }
...@@ -42,7 +50,6 @@ class UnConnectedAlertTab extends PureComponent<Props, State> { ...@@ -42,7 +50,6 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
componentDidMount() { componentDidMount() {
this.loadAlertTab(); this.loadAlertTab();
this.props.panel.events.on(angularPanelUpdated, this.onAngularPanelUpdated);
} }
onAngularPanelUpdated = () => { onAngularPanelUpdated = () => {
...@@ -60,13 +67,13 @@ class UnConnectedAlertTab extends PureComponent<Props, State> { ...@@ -60,13 +67,13 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
} }
async loadAlertTab() { async loadAlertTab() {
const { panel } = this.props; const { panel, angularPanelComponent } = this.props;
if (!this.element || !panel.angularPanel || this.component) { if (!this.element || !angularPanelComponent || this.component) {
return; return;
} }
const scope = panel.angularPanel.getScope(); const scope = angularPanelComponent.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) { if (!scope.$$childHead) {
...@@ -213,8 +220,12 @@ class UnConnectedAlertTab extends PureComponent<Props, State> { ...@@ -213,8 +220,12 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
} }
} }
export const mapStateToProps = (state: StoreState) => ({}); const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent,
};
};
const mapDispatchToProps = { changePanelEditorTab }; const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { changePanelEditorTab };
export const AlertTab = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedAlertTab)); export const AlertTab = connect(mapStateToProps, mapDispatchToProps)(UnConnectedAlertTab);
// Libraries
import React, { PureComponent } from 'react';
import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
// Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
// Types
import { PanelModel, DashboardModel } from '../../state';
import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { PanelCtrl } from 'app/plugins/sdk';
import { changePanelPlugin } from '../../state/actions';
import { StoreState } from 'app/types';
interface OwnProps {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
}
interface ConnectedProps {
angularPanelComponent: AngularComponent;
}
interface DispatchProps {
changePanelPlugin: typeof changePanelPlugin;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
export class AngularPanelOptionsUnconnected extends PureComponent<Props> {
element?: HTMLElement;
angularOptions: AngularComponent;
constructor(props: Props) {
super(props);
}
componentDidMount() {
this.loadAngularOptions();
}
componentDidUpdate(prevProps: Props) {
if (this.props.plugin !== prevProps.plugin) {
this.cleanUpAngularOptions();
}
this.loadAngularOptions();
}
componentWillUnmount() {
this.cleanUpAngularOptions();
}
cleanUpAngularOptions() {
if (this.angularOptions) {
this.angularOptions.destroy();
this.angularOptions = null;
}
}
loadAngularOptions() {
const { panel, angularPanelComponent, changePanelPlugin } = this.props;
if (!this.element || !angularPanelComponent || this.angularOptions) {
return;
}
const scope = angularPanelComponent.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {
setTimeout(() => {
this.forceUpdate();
});
return;
}
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode();
panelCtrl.onPluginTypeChange = (plugin: PanelPluginMeta) => {
changePanelPlugin(panel, plugin.id);
};
let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template +=
`
<div class="panel-options-group" ng-cloak>` +
(i > 0
? `<div class="panel-options-group__header">
<span class="panel-options-group__title">{{ctrl.editorTabs[${i}].title}}
</span>
</div>`
: '') +
`<div class="panel-options-group__body">
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
</div>
</div>
`;
}
const loader = getAngularLoader();
const scopeProps = { ctrl: panelCtrl };
this.angularOptions = loader.load(this.element, scopeProps, template);
}
render() {
return <div ref={elem => (this.element = elem)} />;
}
}
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { changePanelPlugin };
export const AngularPanelOptions = connect(mapStateToProps, mapDispatchToProps)(AngularPanelOptionsUnconnected);
...@@ -27,6 +27,7 @@ import { FieldConfigEditor } from './FieldConfigEditor'; ...@@ -27,6 +27,7 @@ import { FieldConfigEditor } from './FieldConfigEditor';
import { OptionsGroup } from './OptionsGroup'; import { OptionsGroup } from './OptionsGroup';
import { getPanelEditorTabs } from './state/selectors'; import { getPanelEditorTabs } from './state/selectors';
import { getPanelStateById } from '../../state/selectors'; import { getPanelStateById } from '../../state/selectors';
import { AngularPanelOptions } from './AngularPanelOptions';
enum Pane { enum Pane {
Right, Right,
...@@ -99,12 +100,12 @@ export class PanelEditorUnconnected extends PureComponent<Props> { ...@@ -99,12 +100,12 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
this.forceUpdate(); this.forceUpdate();
}; };
renderFieldOptions() { renderFieldOptions(plugin: PanelPlugin) {
const { plugin, panel, data } = this.props; const { panel, data } = this.props;
const fieldOptions = panel.options['fieldOptions'] as FieldConfigSource; const fieldOptions = panel.options['fieldOptions'] as FieldConfigSource;
if (!fieldOptions || !plugin) { if (!fieldOptions) {
return null; return null;
} }
...@@ -123,16 +124,8 @@ export class PanelEditorUnconnected extends PureComponent<Props> { ...@@ -123,16 +124,8 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
this.forceUpdate(); this.forceUpdate();
}; };
/** renderPanelSettings(plugin: PanelPlugin) {
* The existing visualization tab const { data, panel, dashboard } = this.props;
*/
renderVisSettings() {
const { data, panel } = this.props;
const { plugin } = this.props;
if (!plugin) {
return null;
}
if (plugin.editor && panel) { if (plugin.editor && panel) {
return ( return (
...@@ -142,7 +135,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> { ...@@ -142,7 +135,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
); );
} }
return <div>No editor (angular?)</div>; return <AngularPanelOptions panel={panel} dashboard={dashboard} plugin={plugin} />;
} }
onDragFinished = (pane: Pane, size: number) => { onDragFinished = (pane: Pane, size: number) => {
...@@ -260,11 +253,17 @@ export class PanelEditorUnconnected extends PureComponent<Props> { ...@@ -260,11 +253,17 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
} }
renderOptionsPane(styles: any) { renderOptionsPane(styles: any) {
const { plugin } = this.props;
return ( return (
<div className={styles.panelOptionsPane}> <div className={styles.panelOptionsPane}>
<CustomScrollbar> <CustomScrollbar>
{this.renderFieldOptions()} {plugin && (
<OptionsGroup title="Old settings">{this.renderVisSettings()}</OptionsGroup> <>
{this.renderFieldOptions(plugin)}
<OptionsGroup title={`${plugin.meta.name} options`}>{this.renderPanelSettings(plugin)}</OptionsGroup>
</>
)}
</CustomScrollbar> </CustomScrollbar>
</div> </div>
); );
......
...@@ -2,8 +2,9 @@ import { thunkTester } from '../../../../../../test/core/thunk/thunkTester'; ...@@ -2,8 +2,9 @@ import { thunkTester } from '../../../../../../test/core/thunk/thunkTester';
import { initialState } from './reducers'; import { initialState } from './reducers';
import { initPanelEditor, panelEditorCleanUp } from './actions'; import { initPanelEditor, panelEditorCleanUp } from './actions';
import { PanelEditorStateNew, closeCompleted } from './reducers'; import { PanelEditorStateNew, closeCompleted } from './reducers';
import { cleanUpEditPanel } from '../../../state/reducers'; import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers';
import { PanelModel, DashboardModel } from '../../../state'; import { PanelModel, DashboardModel } from '../../../state';
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
describe('panelEditor actions', () => { describe('panelEditor actions', () => {
describe('initPanelEditor', () => { describe('initPanelEditor', () => {
...@@ -27,7 +28,7 @@ describe('panelEditor actions', () => { ...@@ -27,7 +28,7 @@ describe('panelEditor actions', () => {
}); });
describe('panelEditorCleanUp', () => { describe('panelEditorCleanUp', () => {
it('create update source panel', async () => { it('should update source panel', async () => {
const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); const sourcePanel = new PanelModel({ id: 12, type: 'graph' });
const dashboard = new DashboardModel({ const dashboard = new DashboardModel({
panels: [{ id: 12, type: 'graph' }], panels: [{ id: 12, type: 'graph' }],
...@@ -58,5 +59,66 @@ describe('panelEditor actions', () => { ...@@ -58,5 +59,66 @@ describe('panelEditor actions', () => {
expect(sourcePanel.getOptions()).toEqual({ prop: true }); expect(sourcePanel.getOptions()).toEqual({ prop: true });
expect(sourcePanel.id).toEqual(12); expect(sourcePanel.id).toEqual(12);
}); });
it('should dispatch panelModelAndPluginReady if type changed', async () => {
const sourcePanel = new PanelModel({ id: 12, type: 'graph' });
const dashboard = new DashboardModel({
panels: [{ id: 12, type: 'graph' }],
});
const panel = sourcePanel.getEditClone();
panel.type = 'table';
panel.plugin = getPanelPlugin({ id: 'table' });
panel.updateOptions({ prop: true });
const state: PanelEditorStateNew = {
...initialState,
getPanel: () => panel,
getSourcePanel: () => sourcePanel,
querySubscription: { unsubscribe: jest.fn() },
};
const dispatchedActions = await thunkTester({
panelEditorNew: state,
dashboard: {
getModel: () => dashboard,
},
})
.givenThunk(panelEditorCleanUp)
.whenThunkIsDispatched();
expect(dispatchedActions.length).toBe(3);
expect(dispatchedActions[0].type).toBe(panelModelAndPluginReady.type);
});
it('should discard changes when shouldDiscardChanges is true', async () => {
const sourcePanel = new PanelModel({ id: 12, type: 'graph' });
const dashboard = new DashboardModel({
panels: [{ id: 12, type: 'graph' }],
});
const panel = sourcePanel.getEditClone();
panel.updateOptions({ prop: true });
const state: PanelEditorStateNew = {
...initialState,
shouldDiscardChanges: true,
getPanel: () => panel,
getSourcePanel: () => sourcePanel,
querySubscription: { unsubscribe: jest.fn() },
};
const dispatchedActions = await thunkTester({
panelEditorNew: state,
dashboard: {
getModel: () => dashboard,
},
})
.givenThunk(panelEditorCleanUp)
.whenThunkIsDispatched();
expect(dispatchedActions.length).toBe(2);
expect(sourcePanel.getOptions()).toEqual({});
});
}); });
}); });
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
setPanelEditorUIState, setPanelEditorUIState,
PANEL_EDITOR_UI_STATE_STORAGE_KEY, PANEL_EDITOR_UI_STATE_STORAGE_KEY,
} from './reducers'; } from './reducers';
import { cleanUpEditPanel } from '../../../state/reducers'; import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers';
import store from '../../../../../core/store'; import store from '../../../../../core/store';
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> { export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> {
...@@ -40,17 +40,22 @@ export function panelEditorCleanUp(): ThunkResult<void> { ...@@ -40,17 +40,22 @@ export function panelEditorCleanUp(): ThunkResult<void> {
const panel = getPanel(); const panel = getPanel();
const modifiedSaveModel = panel.getSaveModel(); const modifiedSaveModel = panel.getSaveModel();
const sourcePanel = getSourcePanel(); const sourcePanel = getSourcePanel();
const panelTypeChanged = sourcePanel.type !== panel.type;
// restore the source panel id before we update source panel // restore the source panel id before we update source panel
modifiedSaveModel.id = sourcePanel.id; modifiedSaveModel.id = sourcePanel.id;
sourcePanel.restoreModel(modifiedSaveModel); sourcePanel.restoreModel(modifiedSaveModel);
if (panelTypeChanged) {
dispatch(panelModelAndPluginReady({ panelId: sourcePanel.id, plugin: panel.plugin }));
}
// Resend last query result on source panel query runner // Resend last query result on source panel query runner
// But do this after the panel edit editor exit process has completed // But do this after the panel edit editor exit process has completed
setTimeout(() => { setTimeout(() => {
sourcePanel.getQueryRunner().pipeDataToSubject(panel.getQueryRunner().getLastResult()); sourcePanel.getQueryRunner().pipeDataToSubject(panel.getQueryRunner().getLastResult());
}); }, 20);
} }
dashboard.exitPanelEditor(); dashboard.exitPanelEditor();
......
...@@ -67,7 +67,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ...@@ -67,7 +67,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -181,7 +180,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ...@@ -181,7 +180,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -275,7 +273,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` ...@@ -275,7 +273,6 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -401,7 +398,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti ...@@ -401,7 +398,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -513,7 +509,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti ...@@ -513,7 +509,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -610,7 +605,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti ...@@ -610,7 +605,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -704,7 +698,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti ...@@ -704,7 +698,6 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
......
...@@ -160,8 +160,13 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> { ...@@ -160,8 +160,13 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
} }
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => { const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
const panelState = state.dashboard.panels[props.panel.id];
if (!panelState) {
return { plugin: null };
}
return { return {
plugin: state.plugins.panels[props.panel.type], plugin: panelState.plugin,
}; };
}; };
......
...@@ -2,16 +2,22 @@ ...@@ -2,16 +2,22 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
// Components // Components
import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeader } from './PanelHeader/PanelHeader';
// Utils & Services // Utils & Services
import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { getAngularLoader } from '@grafana/runtime'; import { getAngularLoader, AngularComponent } from '@grafana/runtime';
import { setPanelAngularComponent } from '../state/reducers';
// Types // Types
import { DashboardModel, PanelModel } from '../state'; import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data'; import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
export interface Props { interface OwnProps {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
plugin: PanelPlugin; plugin: PanelPlugin;
...@@ -21,6 +27,16 @@ export interface Props { ...@@ -21,6 +27,16 @@ export interface Props {
height: number; height: number;
} }
interface ConnectedProps {
angularComponent: AngularComponent;
}
interface DispatchProps {
setPanelAngularComponent: typeof setPanelAngularComponent;
}
export type Props = OwnProps & ConnectedProps & DispatchProps;
export interface State { export interface State {
data: PanelData; data: PanelData;
errorMessage?: string; errorMessage?: string;
...@@ -36,7 +52,7 @@ interface AngularScopeProps { ...@@ -36,7 +52,7 @@ interface AngularScopeProps {
}; };
} }
export class PanelChromeAngular extends PureComponent<Props, State> { export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
element: HTMLElement | null = null; element: HTMLElement | null = null;
timeSrv: TimeSrv = getTimeSrv(); timeSrv: TimeSrv = getTimeSrv();
scopeProps?: AngularScopeProps; scopeProps?: AngularScopeProps;
...@@ -127,10 +143,10 @@ export class PanelChromeAngular extends PureComponent<Props, State> { ...@@ -127,10 +143,10 @@ export class PanelChromeAngular extends PureComponent<Props, State> {
} }
loadAngularPanel() { loadAngularPanel() {
const { panel, dashboard, height, width } = this.props; const { panel, dashboard, height, width, setPanelAngularComponent } = this.props;
// if we have no element or already have loaded the panel return // if we have no element or already have loaded the panel return
if (!this.element || panel.angularPanel) { if (!this.element) {
return; return;
} }
...@@ -143,19 +159,23 @@ export class PanelChromeAngular extends PureComponent<Props, State> { ...@@ -143,19 +159,23 @@ export class PanelChromeAngular extends PureComponent<Props, State> {
size: { width, height }, size: { width, height },
}; };
// compile angular template and get back handle to scope setPanelAngularComponent({
panel.setAngularPanel(loader.load(this.element, this.scopeProps, template)); panelId: panel.id,
angularComponent: loader.load(this.element, this.scopeProps, template),
});
// need to to this every time we load an angular as all events are unsubscribed when panel is destroyed // need to to this every time we load an angular as all events are unsubscribed when panel is destroyed
this.subscribeToRenderEvent(); this.subscribeToRenderEvent();
} }
cleanUpAngularPanel() { cleanUpAngularPanel() {
const { panel } = this.props; const { angularComponent, setPanelAngularComponent, panel } = this.props;
if (panel.angularPanel) { if (angularComponent) {
panel.setAngularPanel(undefined); angularComponent.destroy();
} }
setPanelAngularComponent({ panelId: panel.id, angularComponent: null });
} }
hasOverlayHeader() { hasOverlayHeader() {
...@@ -176,7 +196,7 @@ export class PanelChromeAngular extends PureComponent<Props, State> { ...@@ -176,7 +196,7 @@ export class PanelChromeAngular extends PureComponent<Props, State> {
} }
render() { render() {
const { dashboard, panel, isFullscreen, plugin } = this.props; const { dashboard, panel, isFullscreen, plugin, angularComponent } = this.props;
const { errorMessage, data, alertState } = this.state; const { errorMessage, data, alertState } = this.state;
const { transparent } = panel; const { transparent } = panel;
...@@ -203,6 +223,7 @@ export class PanelChromeAngular extends PureComponent<Props, State> { ...@@ -203,6 +223,7 @@ export class PanelChromeAngular extends PureComponent<Props, State> {
title={panel.title} title={panel.title}
description={panel.description} description={panel.description}
scopedVars={panel.scopedVars} scopedVars={panel.scopedVars}
angularComponent={angularComponent}
links={panel.links} links={panel.links}
error={errorMessage} error={errorMessage}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
...@@ -215,3 +236,13 @@ export class PanelChromeAngular extends PureComponent<Props, State> { ...@@ -215,3 +236,13 @@ export class PanelChromeAngular extends PureComponent<Props, State> {
); );
} }
} }
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularComponent: state.dashboard.panels[props.panel.id].angularComponent,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent };
export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected);
...@@ -2,6 +2,7 @@ import React, { Component } from 'react'; ...@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { DataLink, ScopedVars, PanelMenuItem } from '@grafana/data'; import { DataLink, ScopedVars, PanelMenuItem } from '@grafana/data';
import { AngularComponent } from '@grafana/runtime';
import { ClickOutsideWrapper } from '@grafana/ui'; import { ClickOutsideWrapper } from '@grafana/ui';
import { e2e } from '@grafana/e2e'; import { e2e } from '@grafana/e2e';
...@@ -21,6 +22,7 @@ export interface Props { ...@@ -21,6 +22,7 @@ export interface Props {
title?: string; title?: string;
description?: string; description?: string;
scopedVars?: ScopedVars; scopedVars?: ScopedVars;
angularComponent?: AngularComponent;
links?: DataLink[]; links?: DataLink[];
error?: string; error?: string;
isFullscreen: boolean; isFullscreen: boolean;
...@@ -67,8 +69,8 @@ export class PanelHeader extends Component<Props, State> { ...@@ -67,8 +69,8 @@ export class PanelHeader extends Component<Props, State> {
event.stopPropagation(); event.stopPropagation();
const { dashboard, panel } = this.props; const { dashboard, panel, angularComponent } = this.props;
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, angularComponent);
this.setState({ this.setState({
panelMenuOpen: !this.state.panelMenuOpen, panelMenuOpen: !this.state.panelMenuOpen,
......
...@@ -143,7 +143,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -143,7 +143,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -171,7 +170,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -171,7 +170,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -199,7 +197,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -199,7 +197,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -227,7 +224,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -227,7 +224,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -278,7 +274,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -278,7 +274,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -390,7 +385,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -390,7 +385,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -418,7 +412,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -418,7 +412,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -446,7 +439,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -446,7 +439,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -474,7 +466,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -474,7 +466,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -525,7 +516,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -525,7 +516,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -637,7 +627,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -637,7 +627,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -665,7 +654,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -665,7 +654,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -693,7 +681,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -693,7 +681,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -721,7 +708,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -721,7 +708,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -772,7 +758,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -772,7 +758,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -884,7 +869,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -884,7 +869,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -912,7 +896,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -912,7 +896,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -940,7 +923,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -940,7 +923,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -968,7 +950,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -968,7 +950,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
...@@ -1019,7 +1000,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` ...@@ -1019,7 +1000,6 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"restoreModel": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
......
// Libraries // Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
// Utils & Services // Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime'; import { AngularComponent, getAngularLoader } from '@grafana/runtime';
// Types // Types
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
import { angularPanelUpdated } from '../state/PanelModel';
import { PanelPlugin, PanelPluginMeta } from '@grafana/data'; import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { PanelCtrl } from 'app/plugins/sdk'; import { PanelCtrl } from 'app/plugins/sdk';
import { changePanelPlugin } from '../state/actions';
import { StoreState } from 'app/types';
interface Props { interface OwnProps {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
plugin: PanelPlugin; plugin: PanelPlugin;
onPluginTypeChange: (newType: PanelPluginMeta) => void;
} }
export class AngularPanelOptions extends PureComponent<Props> { interface ConnectedProps {
angularPanelComponent: AngularComponent;
}
interface DispatchProps {
changePanelPlugin: typeof changePanelPlugin;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
export class AngularPanelOptionsUnconnected extends PureComponent<Props> {
element?: HTMLElement; element?: HTMLElement;
angularOptions: AngularComponent; angularOptions: AngularComponent;
...@@ -25,13 +36,8 @@ export class AngularPanelOptions extends PureComponent<Props> { ...@@ -25,13 +36,8 @@ export class AngularPanelOptions extends PureComponent<Props> {
componentDidMount() { componentDidMount() {
this.loadAngularOptions(); this.loadAngularOptions();
this.props.panel.events.on(angularPanelUpdated, this.onAngularPanelUpdated);
} }
onAngularPanelUpdated = () => {
this.forceUpdate();
};
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
if (this.props.plugin !== prevProps.plugin) { if (this.props.plugin !== prevProps.plugin) {
this.cleanUpAngularOptions(); this.cleanUpAngularOptions();
...@@ -42,7 +48,6 @@ export class AngularPanelOptions extends PureComponent<Props> { ...@@ -42,7 +48,6 @@ export class AngularPanelOptions extends PureComponent<Props> {
componentWillUnmount() { componentWillUnmount() {
this.cleanUpAngularOptions(); this.cleanUpAngularOptions();
this.props.panel.events.off(angularPanelUpdated, this.onAngularPanelUpdated);
} }
cleanUpAngularOptions() { cleanUpAngularOptions() {
...@@ -53,13 +58,13 @@ export class AngularPanelOptions extends PureComponent<Props> { ...@@ -53,13 +58,13 @@ export class AngularPanelOptions extends PureComponent<Props> {
} }
loadAngularOptions() { loadAngularOptions() {
const { panel } = this.props; const { panel, angularPanelComponent, changePanelPlugin } = this.props;
if (!this.element || !panel.angularPanel || this.angularOptions) { if (!this.element || !angularPanelComponent || this.angularOptions) {
return; return;
} }
const scope = panel.angularPanel.getScope(); const scope = angularPanelComponent.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) { if (!scope.$$childHead) {
...@@ -71,7 +76,9 @@ export class AngularPanelOptions extends PureComponent<Props> { ...@@ -71,7 +76,9 @@ export class AngularPanelOptions extends PureComponent<Props> {
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl; const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode(); panelCtrl.initEditMode();
panelCtrl.onPluginTypeChange = this.props.onPluginTypeChange; panelCtrl.onPluginTypeChange = (plugin: PanelPluginMeta) => {
changePanelPlugin(panel, plugin.id);
};
let template = ''; let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) { for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
...@@ -101,3 +108,13 @@ export class AngularPanelOptions extends PureComponent<Props> { ...@@ -101,3 +108,13 @@ export class AngularPanelOptions extends PureComponent<Props> {
return <div ref={elem => (this.element = elem)} />; return <div ref={elem => (this.element = elem)} />;
} }
} }
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { changePanelPlugin };
export const AngularPanelOptions = connect(mapStateToProps, mapDispatchToProps)(AngularPanelOptionsUnconnected);
// Libraries // Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Utils & Services // Utils & Services
import { AngularComponent } from '@grafana/runtime';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
...@@ -37,7 +36,6 @@ interface State { ...@@ -37,7 +36,6 @@ interface State {
export class VisualizationTab extends PureComponent<Props, State> { export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement; element: HTMLElement;
angularOptions: AngularComponent;
querySubscription: Unsubscribable; querySubscription: Unsubscribable;
constructor(props: Props) { constructor(props: Props) {
...@@ -65,14 +63,7 @@ export class VisualizationTab extends PureComponent<Props, State> { ...@@ -65,14 +63,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
const { plugin, dashboard, panel } = this.props; const { plugin, dashboard, panel } = this.props;
if (plugin.angularPanelCtrl) { if (plugin.angularPanelCtrl) {
return ( return <AngularPanelOptions plugin={plugin} dashboard={dashboard} panel={panel} />;
<AngularPanelOptions
plugin={plugin}
dashboard={dashboard}
panel={panel}
onPluginTypeChange={this.onPluginTypeChange}
/>
);
} }
if (plugin.editor) { if (plugin.editor) {
......
...@@ -146,23 +146,6 @@ describe('PanelModel', () => { ...@@ -146,23 +146,6 @@ describe('PanelModel', () => {
}); });
}); });
describe('when changing from angular panel', () => {
const angularPanel = {
scope: {},
destroy: jest.fn(),
};
beforeEach(() => {
model.angularPanel = angularPanel;
model.changePlugin(getPanelPlugin({ id: 'graph' }));
});
it('should set angularPanel to undefined and call destory', () => {
expect(angularPanel.destroy.mock.calls.length).toBe(1);
expect(model.angularPanel).toBe(undefined);
});
});
describe('when changing to react panel from angular panel', () => { describe('when changing to react panel from angular panel', () => {
let panelQueryRunner: any; let panelQueryRunner: any;
......
...@@ -13,7 +13,6 @@ import { ...@@ -13,7 +13,6 @@ import {
DataTransformerConfig, DataTransformerConfig,
ScopedVars, ScopedVars,
} from '@grafana/data'; } from '@grafana/data';
import { AngularComponent } from '@grafana/runtime';
import { EDIT_PANEL_ID } from 'app/core/constants'; import { EDIT_PANEL_ID } from 'app/core/constants';
import config from 'app/core/config'; import config from 'app/core/config';
...@@ -24,7 +23,6 @@ import { take } from 'rxjs/operators'; ...@@ -24,7 +23,6 @@ import { take } from 'rxjs/operators';
export const panelAdded = eventFactory<PanelModel | undefined>('panel-added'); export const panelAdded = eventFactory<PanelModel | undefined>('panel-added');
export const panelRemoved = eventFactory<PanelModel | undefined>('panel-removed'); export const panelRemoved = eventFactory<PanelModel | undefined>('panel-removed');
export const angularPanelUpdated = eventFactory('panel-angular-panel-updated');
export interface GridPos { export interface GridPos {
x: number; x: number;
...@@ -43,8 +41,6 @@ const notPersistedProperties: { [str: string]: boolean } = { ...@@ -43,8 +41,6 @@ const notPersistedProperties: { [str: string]: boolean } = {
cachedPluginOptions: true, cachedPluginOptions: true,
plugin: true, plugin: true,
queryRunner: true, queryRunner: true,
angularPanel: true,
restoreModel: true,
}; };
// For angular panels we need to clean up properties when changing type // For angular panels we need to clean up properties when changing type
...@@ -139,7 +135,6 @@ export class PanelModel { ...@@ -139,7 +135,6 @@ export class PanelModel {
cachedPluginOptions?: any; cachedPluginOptions?: any;
legend?: { show: boolean }; legend?: { show: boolean };
plugin?: PanelPlugin; plugin?: PanelPlugin;
angularPanel?: AngularComponent;
private queryRunner?: PanelQueryRunner; private queryRunner?: PanelQueryRunner;
...@@ -152,7 +147,7 @@ export class PanelModel { ...@@ -152,7 +147,7 @@ export class PanelModel {
} }
/** Given a persistened PanelModel restores property values */ /** Given a persistened PanelModel restores property values */
restoreModel = (model: any) => { restoreModel(model: any) {
// copy properties from persisted model // copy properties from persisted model
for (const property in model) { for (const property in model) {
(this as any)[property] = model[property]; (this as any)[property] = model[property];
...@@ -163,7 +158,7 @@ export class PanelModel { ...@@ -163,7 +158,7 @@ export class PanelModel {
// queries must have refId // queries must have refId
this.ensureQueryIds(); this.ensureQueryIds();
}; }
ensureQueryIds() { ensureQueryIds() {
if (this.targets && _.isArray(this.targets)) { if (this.targets && _.isArray(this.targets)) {
...@@ -296,10 +291,6 @@ export class PanelModel { ...@@ -296,10 +291,6 @@ export class PanelModel {
const oldPluginId = this.type; const oldPluginId = this.type;
const wasAngular = !!this.plugin.angularPanelCtrl; const wasAngular = !!this.plugin.angularPanelCtrl;
if (this.angularPanel) {
this.setAngularPanel(undefined);
}
// remove panel type specific options // remove panel type specific options
for (const key of _.keys(this)) { for (const key of _.keys(this)) {
if (mustKeepProps[key]) { if (mustKeepProps[key]) {
...@@ -395,26 +386,12 @@ export class PanelModel { ...@@ -395,26 +386,12 @@ export class PanelModel {
this.queryRunner.destroy(); this.queryRunner.destroy();
this.queryRunner = null; this.queryRunner = null;
} }
if (this.angularPanel) {
this.angularPanel.destroy();
}
} }
setTransformations(transformations: DataTransformerConfig[]) { setTransformations(transformations: DataTransformerConfig[]) {
this.transformations = transformations; this.transformations = transformations;
this.getQueryRunner().setTransformations(transformations); this.getQueryRunner().setTransformations(transformations);
} }
setAngularPanel(component: AngularComponent) {
if (this.angularPanel) {
// this will remove all event listeners
this.angularPanel.destroy();
}
this.angularPanel = component;
this.events.emit(angularPanelUpdated);
}
} }
function getPluginVersion(plugin: PanelPlugin): string { function getPluginVersion(plugin: PanelPlugin): string {
......
...@@ -3,7 +3,7 @@ import { getBackendSrv } from '@grafana/runtime'; ...@@ -3,7 +3,7 @@ import { getBackendSrv } from '@grafana/runtime';
import { createSuccessNotification } from 'app/core/copy/appNotification'; import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions // Actions
import { loadPluginDashboards } from '../../plugins/state/actions'; import { loadPluginDashboards } from '../../plugins/state/actions';
import { loadDashboardPermissions, panelModelAndPluginReady } from './reducers'; import { loadDashboardPermissions, panelModelAndPluginReady, setPanelAngularComponent } from './reducers';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { loadPanelPlugin } from 'app/features/plugins/state/actions'; import { loadPanelPlugin } from 'app/features/plugins/state/actions';
// Types // Types
...@@ -134,12 +134,20 @@ export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkRes ...@@ -134,12 +134,20 @@ export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkRes
return; return;
} }
let plugin = getStore().plugins.panels[pluginId]; const store = getStore();
let plugin = store.plugins.panels[pluginId];
if (!plugin) { if (!plugin) {
plugin = await dispatch(loadPanelPlugin(pluginId)); plugin = await dispatch(loadPanelPlugin(pluginId));
} }
// clean up angular component (scope / ctrl state)
const angularComponent = store.dashboard.panels[panel.id].angularComponent;
if (angularComponent) {
angularComponent.destroy();
dispatch(setPanelAngularComponent({ panelId: panel.id, angularComponent: null }));
}
panel.changePlugin(plugin); panel.changePlugin(plugin);
dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin }));
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
PanelState, PanelState,
QueriesToUpdateOnDashboardLoad, QueriesToUpdateOnDashboardLoad,
} from 'app/types'; } from 'app/types';
import { AngularComponent } from '@grafana/runtime';
import { EDIT_PANEL_ID } from 'app/core/constants'; import { EDIT_PANEL_ID } from 'app/core/constants';
import { processAclItems } from 'app/core/utils/acl'; import { processAclItems } from 'app/core/utils/acl';
import { panelEditorReducer } from '../panel_editor/state/reducers'; import { panelEditorReducer } from '../panel_editor/state/reducers';
...@@ -82,6 +83,9 @@ const dashbardSlice = createSlice({ ...@@ -82,6 +83,9 @@ const dashbardSlice = createSlice({
cleanUpEditPanel: (state, action: PayloadAction) => { cleanUpEditPanel: (state, action: PayloadAction) => {
delete state.panels[EDIT_PANEL_ID]; delete state.panels[EDIT_PANEL_ID];
}, },
setPanelAngularComponent: (state: DashboardState, action: PayloadAction<SetPanelAngularComponentPayload>) => {
updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent });
},
addPanel: (state, action: PayloadAction<PanelModel>) => { addPanel: (state, action: PayloadAction<PanelModel>) => {
state.panels[action.payload.id] = { pluginId: action.payload.type }; state.panels[action.payload.id] = { pluginId: action.payload.type };
}, },
...@@ -101,6 +105,11 @@ export interface PanelModelAndPluginReadyPayload { ...@@ -101,6 +105,11 @@ export interface PanelModelAndPluginReadyPayload {
plugin: PanelPlugin; plugin: PanelPlugin;
} }
export interface SetPanelAngularComponentPayload {
panelId: number;
angularComponent: AngularComponent | null;
}
export const { export const {
loadDashboardPermissions, loadDashboardPermissions,
dashboardInitFetching, dashboardInitFetching,
...@@ -114,6 +123,7 @@ export const { ...@@ -114,6 +123,7 @@ export const {
panelModelAndPluginReady, panelModelAndPluginReady,
addPanel, addPanel,
cleanUpEditPanel, cleanUpEditPanel,
setPanelAngularComponent,
} = dashbardSlice.actions; } = dashbardSlice.actions;
export const dashboardReducer = dashbardSlice.reducer; export const dashboardReducer = dashbardSlice.reducer;
......
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
import { store } from 'app/store/store'; import { store } from 'app/store/store';
import config from 'app/core/config'; import config from 'app/core/config';
import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime'; import { getDataSourceSrv, getLocationSrv, AngularComponent } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data'; import { PanelMenuItem } from '@grafana/data';
import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel'; import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
...@@ -12,7 +12,11 @@ import { getExploreUrl } from '../../../core/utils/explore'; ...@@ -12,7 +12,11 @@ import { getExploreUrl } from '../../../core/utils/explore';
import { getTimeSrv } from '../services/TimeSrv'; import { getTimeSrv } from '../services/TimeSrv';
import { PanelCtrl } from '../../panel/panel_ctrl'; import { PanelCtrl } from '../../panel/panel_ctrl';
export function getPanelMenu(dashboard: DashboardModel, panel: PanelModel): PanelMenuItem[] { export function getPanelMenu(
dashboard: DashboardModel,
panel: PanelModel,
angularComponent?: AngularComponent
): PanelMenuItem[] {
const onViewPanel = (event: React.MouseEvent<any>) => { const onViewPanel = (event: React.MouseEvent<any>) => {
event.preventDefault(); event.preventDefault();
store.dispatch( store.dispatch(
...@@ -171,8 +175,8 @@ export function getPanelMenu(dashboard: DashboardModel, panel: PanelModel): Pane ...@@ -171,8 +175,8 @@ export function getPanelMenu(dashboard: DashboardModel, panel: PanelModel): Pane
}); });
// add old angular panel options // add old angular panel options
if (panel.angularPanel) { if (angularComponent) {
const scope = panel.angularPanel.getScope(); const scope = angularComponent.getScope();
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl; const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
const angularMenuItems = panelCtrl.getExtendedMenu(); const angularMenuItems = panelCtrl.getExtendedMenu();
......
...@@ -27,7 +27,6 @@ import { ...@@ -27,7 +27,6 @@ import {
getDataLinksVariableSuggestions, getDataLinksVariableSuggestions,
getCalculationValueDataLinksVariableSuggestions, getCalculationValueDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv'; } from 'app/features/panel/panellinks/link_srv';
import { config } from 'app/core/config';
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> { export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
onThresholdsChanged = (thresholds: ThresholdsConfig) => { onThresholdsChanged = (thresholds: ThresholdsConfig) => {
...@@ -124,11 +123,7 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge ...@@ -124,11 +123,7 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
/> />
</PanelOptionsGroup> </PanelOptionsGroup>
<ThresholdsEditor <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={defaults.thresholds} />
onChange={this.onThresholdsChanged}
thresholds={defaults.thresholds}
showAlphaUI={config.featureToggles.newEdit}
/>
</PanelOptionsGrid> </PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} /> <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
......
...@@ -24,7 +24,6 @@ import { ...@@ -24,7 +24,6 @@ import {
getCalculationValueDataLinksVariableSuggestions, getCalculationValueDataLinksVariableSuggestions,
getDataLinksVariableSuggestions, getDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv'; } from 'app/features/panel/panellinks/link_srv';
import { config } from 'app/core/config';
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> { export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
labelWidth = 6; labelWidth = 6;
...@@ -130,11 +129,7 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption ...@@ -130,11 +129,7 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
/> />
</PanelOptionsGroup> </PanelOptionsGroup>
<ThresholdsEditor <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={defaults.thresholds} />
onChange={this.onThresholdsChanged}
thresholds={defaults.thresholds}
showAlphaUI={config.featureToggles.newEdit}
/>
</PanelOptionsGrid> </PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} /> <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
......
...@@ -29,7 +29,6 @@ import { ...@@ -29,7 +29,6 @@ import {
getDataLinksVariableSuggestions, getDataLinksVariableSuggestions,
getCalculationValueDataLinksVariableSuggestions, getCalculationValueDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv'; } from 'app/features/panel/panellinks/link_srv';
import { config } from 'app/core/config';
export class StatPanelEditor extends PureComponent<PanelEditorProps<StatPanelOptions>> { export class StatPanelEditor extends PureComponent<PanelEditorProps<StatPanelOptions>> {
onThresholdsChanged = (thresholds: ThresholdsConfig) => { onThresholdsChanged = (thresholds: ThresholdsConfig) => {
...@@ -137,11 +136,7 @@ export class StatPanelEditor extends PureComponent<PanelEditorProps<StatPanelOpt ...@@ -137,11 +136,7 @@ export class StatPanelEditor extends PureComponent<PanelEditorProps<StatPanelOpt
/> />
</PanelOptionsGroup> </PanelOptionsGroup>
<ThresholdsEditor <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={defaults.thresholds} />
onChange={this.onThresholdsChanged}
thresholds={defaults.thresholds}
showAlphaUI={config.featureToggles.newEdit}
/>
</PanelOptionsGrid> </PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} /> <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
......
import { DashboardAcl } from './acl'; import { DashboardAcl } from './acl';
import { DataQuery, PanelPlugin } from '@grafana/data'; import { DataQuery, PanelPlugin } from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { AngularComponent } from '@grafana/runtime';
export interface DashboardDTO { export interface DashboardDTO {
redirectUri?: string; redirectUri?: string;
...@@ -70,6 +71,7 @@ export interface QueriesToUpdateOnDashboardLoad { ...@@ -70,6 +71,7 @@ export interface QueriesToUpdateOnDashboardLoad {
export interface PanelState { export interface PanelState {
pluginId: string; pluginId: string;
plugin?: PanelPlugin; plugin?: PanelPlugin;
angularComponent?: AngularComponent | null;
} }
export interface DashboardState { export interface DashboardState {
......
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