diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index bd535655551..d6f3879d358 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -21,27 +21,32 @@ export interface PanelEditorProps { onOptionsChange: (options: T) => void; } +export interface PanelModel { + id: number; + options: TOptions; + pluginVersion?: string; +} + /** - * Called when a panel is first loaded with existing options + * Called when a panel is first loaded with current panel model */ -export type PanelMigrationHook = (options: Partial) => Partial; +export type PanelMigrationHandler = (panel: PanelModel) => Partial; /** * Called before a panel is initalized */ -export type PanelTypeChangedHook = ( +export type PanelTypeChangedHandler = ( options: Partial, prevPluginId: string, - prevOptions?: any + prevOptions: any ) => Partial; export class ReactPanelPlugin { panel: ComponentClass>; editor?: ComponentClass>; defaults?: TOptions; - - panelMigrationHook?: PanelMigrationHook; - panelTypeChangedHook?: PanelTypeChangedHook; + onPanelMigration?: PanelMigrationHandler; + onPanelTypeChanged?: PanelTypeChangedHandler; constructor(panel: ComponentClass>) { this.panel = panel; @@ -49,25 +54,32 @@ export class ReactPanelPlugin { setEditor(editor: ComponentClass>) { this.editor = editor; + return this; } setDefaults(defaults: TOptions) { this.defaults = defaults; + return this; } /** - * Called when the panel first loaded with + * This function is called before the panel first loads if + * the current version is different than the version that was saved. + * + * This is a good place to support any changes to the options model */ - setPanelMigrationHook(v: PanelMigrationHook) { - this.panelMigrationHook = v; + setMigrationHandler(handler: PanelMigrationHandler) { + this.onPanelMigration = handler; + return this; } /** - * Called when the visualization changes. - * Lets you keep whatever settings made sense in the previous panel + * This function is called when the visualization was changed. This + * passes in the options that were used in the previous visualization */ - setPanelTypeChangedHook(v: PanelTypeChangedHook) { - this.panelTypeChangedHook = v; + setPanelChangeHandler(handler: PanelTypeChangedHandler) { + this.onPanelTypeChanged = handler; + return this; } } diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index bb1794a5154..dcc20f19ea0 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -81,7 +81,7 @@ export interface PluginExports { // Panel plugin PanelCtrl?: any; - reactPanel: ReactPanelPlugin; + reactPanel?: ReactPanelPlugin; } export interface PluginMeta { diff --git a/public/app/core/utils/emitter.ts b/public/app/core/utils/emitter.ts index 9a5c671f574..95258e7552e 100644 --- a/public/app/core/utils/emitter.ts +++ b/public/app/core/utils/emitter.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'eventemitter3'; export class Emitter { - emitter: any; + private emitter: EventEmitter; constructor() { this.emitter = new EventEmitter(); @@ -29,4 +29,8 @@ export class Emitter { off(name, handler) { this.emitter.off(name, handler); } + + getEventCount(): number { + return (this.emitter as any)._eventsCount; + } } diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 243380c617e..ba9988fbca9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -14,7 +14,6 @@ import { PanelEditor } from '../panel_editor/PanelEditor'; import { PanelModel, DashboardModel } from '../state'; import { PanelPlugin } from 'app/types'; import { PanelResizer } from './PanelResizer'; -import { PanelTypeChangedHook } from '@grafana/ui'; export interface Props { panel: PanelModel; @@ -71,9 +70,6 @@ export class DashboardPanel extends PureComponent { if (!this.state.plugin || this.state.plugin.id !== pluginId) { let plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId); - // remember if this is from an angular panel - const fromAngularPanel = this.state.angularPanel != null; - // unmount angular panel this.cleanUpAngularPanel(); @@ -86,23 +82,9 @@ export class DashboardPanel extends PureComponent { } if (panel.type !== pluginId) { - if (fromAngularPanel) { - // for angular panels only we need to remove all events and let angular panels do some cleanup - panel.destroy(); - - this.props.panel.changeType(pluginId); - } else { - let hook: PanelTypeChangedHook | null = null; - if (plugin.exports.reactPanel) { - hook = plugin.exports.reactPanel.panelTypeChangedHook; - } - panel.changeType(pluginId, hook); - } - } else if (plugin.exports && plugin.exports.reactPanel && panel.options) { - const hook = plugin.exports.reactPanel.panelMigrationHook; - if (hook) { - panel.options = hook(panel.options); - } + panel.changePlugin(plugin); + } else { + panel.pluginLoaded(plugin); } this.setState({ plugin, angularPanel: null }); diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index 82af0804029..1da0c786753 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -1,4 +1,6 @@ import { PanelModel } from './PanelModel'; +import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { ReactPanelPlugin } from '@grafana/ui/src/types/panel'; describe('PanelModel', () => { describe('when creating new panel model', () => { @@ -76,7 +78,7 @@ describe('PanelModel', () => { describe('when changing panel type', () => { beforeEach(() => { - model.changeType('graph'); + model.changePlugin(getPanelPlugin({ id: 'graph', exports: {} })); model.alert = { id: 2 }; }); @@ -85,16 +87,54 @@ describe('PanelModel', () => { }); it('should restore table properties when changing back', () => { - model.changeType('table'); + model.changePlugin(getPanelPlugin({ id: 'table', exports: {} })); expect(model.showColumns).toBe(true); }); it('should remove alert rule when changing type that does not support it', () => { - model.changeType('table'); + model.changePlugin(getPanelPlugin({ id: 'table', exports: {} })); expect(model.alert).toBe(undefined); }); }); + describe('when changing from angular panel', () => { + let tearDownPublished = false; + + beforeEach(() => { + model.events.on('panel-teardown', () => { + tearDownPublished = true; + }); + model.changePlugin(getPanelPlugin({ id: 'graph', exports: {} })); + }); + + it('should teardown / destroy panel so angular panels event subscriptions are removed', () => { + expect(tearDownPublished).toBe(true); + expect(model.events.getEventCount()).toBe(0); + }); + }); + + describe('when changing to react panel', () => { + const onPanelTypeChanged = jest.fn(); + const reactPanel = new ReactPanelPlugin({} as any).setPanelChangeHandler(onPanelTypeChanged as any); + + beforeEach(() => { + model.changePlugin( + getPanelPlugin({ + id: 'react', + exports: { + reactPanel, + }, + }) + ); + }); + + it('should call react onPanelTypeChanged', () => { + expect(onPanelTypeChanged.mock.calls.length).toBe(1); + expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table'); + expect(onPanelTypeChanged.mock.calls[0][2].thresholds).toBeDefined(); + }); + }); + describe('get panel options', () => { it('should apply defaults', () => { model.options = { existingProp: 10 }; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 68f017adb9e..7e67e280241 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -6,8 +6,8 @@ import { Emitter } from 'app/core/utils/emitter'; import { getNextRefIdChar } from 'app/core/utils/query'; // Types -import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui'; -import { TableData } from '@grafana/ui/src'; +import { DataQuery, TimeSeries, Threshold, ScopedVars, TableData } from '@grafana/ui'; +import { PanelPlugin } from 'app/types'; export interface GridPos { x: number; @@ -23,6 +23,7 @@ const notPersistedProperties: { [str: string]: boolean } = { isEditing: true, hasRefreshed: true, cachedPluginOptions: true, + plugin: true, }; // For angular panels we need to clean up properties when changing type @@ -58,6 +59,7 @@ const mustKeepProps: { [str: string]: boolean } = { cacheTimeout: true, cachedPluginOptions: true, transparent: true, + pluginVersion: true, }; const defaults: any = { @@ -87,6 +89,7 @@ export class PanelModel { targets: DataQuery[]; datasource: string; thresholds?: any; + pluginVersion?: string; snapshotData?: TimeSeries[] | [TableData]; timeFrom?: any; @@ -110,6 +113,7 @@ export class PanelModel { cacheTimeout?: any; cachedPluginOptions?: any; legend?: { show: boolean }; + plugin?: PanelPlugin; constructor(model: any) { this.events = new Emitter(); @@ -240,11 +244,27 @@ export class PanelModel { }); } - changeType(pluginId: string, hook?: PanelTypeChangedHook) { + pluginLoaded(plugin: PanelPlugin) { + this.plugin = plugin; + + const { reactPanel } = plugin.exports; + + if (reactPanel && reactPanel.onPanelMigration) { + this.options = reactPanel.onPanelMigration(this); + this.pluginVersion = plugin.info ? plugin.info.version : '1.0.0'; + } + } + + changePlugin(newPlugin: PanelPlugin) { + const pluginId = newPlugin.id; const oldOptions: any = this.getOptionsToRemember(); const oldPluginId = this.type; + const reactPanel = newPlugin.exports.reactPanel; - this.type = pluginId; + // for angular panels we must remove all events and let angular panels do some cleanup + if (!reactPanel) { + this.destroy(); + } // remove panel type specific options for (const key of _.keys(this)) { @@ -258,12 +278,16 @@ export class PanelModel { this.cachedPluginOptions[oldPluginId] = oldOptions; this.restorePanelOptions(pluginId); - // Callback that can validate and migrate any existing settings - if (hook) { - this.options = this.options || {}; - const old = oldOptions ? oldOptions.options : null; + // switch + this.type = pluginId; + this.plugin = newPlugin; - Object.assign(this.options, hook(this.options, oldPluginId, old)); + // Let panel plugins inspect options from previous panel and keep any that it can use + const onPanelTypeChanged = reactPanel ? reactPanel.onPanelTypeChanged : null; + if (onPanelTypeChanged) { + this.options = this.options || {}; + const old = oldOptions ? oldOptions.options : {}; + Object.assign(this.options, onPanelTypeChanged(this.options, oldPluginId, old)); } } diff --git a/public/app/features/plugins/__mocks__/pluginMocks.ts b/public/app/features/plugins/__mocks__/pluginMocks.ts index ab78f8094b3..5350aee0baa 100644 --- a/public/app/features/plugins/__mocks__/pluginMocks.ts +++ b/public/app/features/plugins/__mocks__/pluginMocks.ts @@ -33,7 +33,7 @@ export const getMockPlugins = (amount: number): Plugin[] => { return plugins; }; -export const getPanelPlugin = (options: { id: string; sort?: number; hideFromList?: boolean }): PanelPlugin => { +export const getPanelPlugin = (options: Partial): PanelPlugin => { return { id: options.id, name: options.id, @@ -56,6 +56,7 @@ export const getPanelPlugin = (options: { id: string; sort?: number; hideFromLis hideFromList: options.hideFromList === true, module: '', baseUrl: '', + exports: options.exports, }; }; diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index 3c46adeb4f9..e84c64f3710 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -5,8 +5,7 @@ import { BarGaugePanelEditor } from './BarGaugePanelEditor'; import { BarGaugeOptions, defaults } from './types'; import { singleStatBaseOptionsCheck } from '../singlestat2/module'; -export const reactPanel = new ReactPanelPlugin(BarGaugePanel); - -reactPanel.setEditor(BarGaugePanelEditor); -reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); +export const reactPanel = new ReactPanelPlugin(BarGaugePanel) + .setDefaults(defaults) + .setEditor(BarGaugePanelEditor) + .setPanelChangeHandler(singleStatBaseOptionsCheck); diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index 340af06a080..d811d29029a 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -3,10 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui'; import { GaugePanelEditor } from './GaugePanelEditor'; import { GaugePanel } from './GaugePanel'; import { GaugeOptions, defaults } from './types'; -import { singleStatBaseOptionsCheck } from '../singlestat2/module'; +import { singleStatBaseOptionsCheck, singleStatMigrationCheck } from '../singlestat2/module'; -export const reactPanel = new ReactPanelPlugin(GaugePanel); - -reactPanel.setEditor(GaugePanelEditor); -reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); +export const reactPanel = new ReactPanelPlugin(GaugePanel) + .setDefaults(defaults) + .setEditor(GaugePanelEditor) + .setPanelChangeHandler(singleStatBaseOptionsCheck) + .setMigrationHandler(singleStatMigrationCheck); diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx index 6dd6d4a77c4..8d27c36492f 100644 --- a/public/app/plugins/panel/graph2/module.tsx +++ b/public/app/plugins/panel/graph2/module.tsx @@ -1,8 +1,6 @@ import { ReactPanelPlugin } from '@grafana/ui'; - import { GraphPanelEditor } from './GraphPanelEditor'; import { GraphPanel } from './GraphPanel'; -import { Options } from './types'; +import { Options, defaults } from './types'; -export const reactPanel = new ReactPanelPlugin(GraphPanel); -reactPanel.setEditor(GraphPanelEditor); +export const reactPanel = new ReactPanelPlugin(GraphPanel).setDefaults(defaults).setEditor(GraphPanelEditor); diff --git a/public/app/plugins/panel/graph2/types.ts b/public/app/plugins/panel/graph2/types.ts index b9baaa09cd7..1f6e9c093dd 100644 --- a/public/app/plugins/panel/graph2/types.ts +++ b/public/app/plugins/panel/graph2/types.ts @@ -3,3 +3,9 @@ export interface Options { showLines: boolean; showPoints: boolean; } + +export const defaults: Options = { + showBars: false, + showLines: true, + showPoints: false, +}; diff --git a/public/app/plugins/panel/piechart/PieChartPanelEditor.tsx b/public/app/plugins/panel/piechart/PieChartPanelEditor.tsx index 7a8aae8b7c9..d76a35dbf68 100644 --- a/public/app/plugins/panel/piechart/PieChartPanelEditor.tsx +++ b/public/app/plugins/panel/piechart/PieChartPanelEditor.tsx @@ -6,7 +6,7 @@ import { PieChartOptions } from './types'; import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor'; import { SingleStatValueOptions } from '../singlestat2/types'; -export default class PieChartPanelEditor extends PureComponent> { +export class PieChartPanelEditor extends PureComponent> { onValueMappingsChanged = (valueMappings: ValueMapping[]) => this.props.onOptionsChange({ ...this.props.options, diff --git a/public/app/plugins/panel/piechart/module.tsx b/public/app/plugins/panel/piechart/module.tsx index 0a71ccf32ba..fd9c14a4760 100644 --- a/public/app/plugins/panel/piechart/module.tsx +++ b/public/app/plugins/panel/piechart/module.tsx @@ -1,12 +1,8 @@ import { ReactPanelPlugin } from '@grafana/ui'; - -import PieChartPanelEditor from './PieChartPanelEditor'; +import { PieChartPanelEditor } from './PieChartPanelEditor'; import { PieChartPanel } from './PieChartPanel'; import { PieChartOptions, defaults } from './types'; -import { singleStatBaseOptionsCheck } from '../singlestat2/module'; -export const reactPanel = new ReactPanelPlugin(PieChartPanel); - -reactPanel.setEditor(PieChartPanelEditor); -reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); +export const reactPanel = new ReactPanelPlugin(PieChartPanel) + .setDefaults(defaults) + .setEditor(PieChartPanelEditor); diff --git a/public/app/plugins/panel/singlestat2/module.tsx b/public/app/plugins/panel/singlestat2/module.tsx index 4b2c27c360a..d7e2667d605 100644 --- a/public/app/plugins/panel/singlestat2/module.tsx +++ b/public/app/plugins/panel/singlestat2/module.tsx @@ -1,39 +1,39 @@ -import { ReactPanelPlugin, getStatsCalculators } from '@grafana/ui'; +import { ReactPanelPlugin, getStatsCalculators, PanelModel } from '@grafana/ui'; import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types'; import { SingleStatPanel } from './SingleStatPanel'; import cloneDeep from 'lodash/cloneDeep'; import { SingleStatEditor } from './SingleStatEditor'; -export const reactPanel = new ReactPanelPlugin(SingleStatPanel); - const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings']; export const singleStatBaseOptionsCheck = ( options: Partial, prevPluginId: string, - prevOptions?: any + prevOptions: any ) => { - if (prevOptions) { - optionsToKeep.forEach(v => { - if (prevOptions.hasOwnProperty(v)) { - options[v] = cloneDeep(prevOptions.display); - } - }); + optionsToKeep.forEach(v => { + if (prevOptions.hasOwnProperty(v)) { + options[v] = cloneDeep(prevOptions.display); + } + }); + return options; +}; + +export const singleStatMigrationCheck = (panel: PanelModel) => { + const options = panel.options; + if (options.valueOptions) { + // 6.1 renamed some stats, This makes sure they are up to date + // avg -> mean, current -> last, total -> sum + const { valueOptions } = options; + if (valueOptions && valueOptions.stat) { + valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0]; + } } return options; }; -export const singleStatMigrationCheck = (options: Partial) => { - // 6.1 renamed some stats, This makes sure they are up to date - // avg -> mean, current -> last, total -> sum - const { valueOptions } = options; - if (valueOptions && valueOptions.stat) { - valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0]; - } - return options; -}; - -reactPanel.setEditor(SingleStatEditor); -reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); -reactPanel.setPanelMigrationHook(singleStatMigrationCheck); +export const reactPanel = new ReactPanelPlugin(SingleStatPanel) + .setDefaults(defaults) + .setEditor(SingleStatEditor) + .setPanelChangeHandler(singleStatMigrationCheck) + .setMigrationHandler(singleStatMigrationCheck); diff --git a/public/app/plugins/panel/table2/module.tsx b/public/app/plugins/panel/table2/module.tsx index d93e7911074..ed04e6867b5 100644 --- a/public/app/plugins/panel/table2/module.tsx +++ b/public/app/plugins/panel/table2/module.tsx @@ -4,6 +4,4 @@ import { TablePanelEditor } from './TablePanelEditor'; import { TablePanel } from './TablePanel'; import { Options, defaults } from './types'; -export const reactPanel = new ReactPanelPlugin(TablePanel); -reactPanel.setEditor(TablePanelEditor); -reactPanel.setDefaults(defaults); +export const reactPanel = new ReactPanelPlugin(TablePanel).setDefaults(defaults).setEditor(TablePanelEditor); diff --git a/public/app/plugins/panel/text2/module.tsx b/public/app/plugins/panel/text2/module.tsx index b2e3057de34..29d5167463e 100644 --- a/public/app/plugins/panel/text2/module.tsx +++ b/public/app/plugins/panel/text2/module.tsx @@ -4,14 +4,12 @@ import { TextPanelEditor } from './TextPanelEditor'; import { TextPanel } from './TextPanel'; import { TextOptions, defaults } from './types'; -export const reactPanel = new ReactPanelPlugin(TextPanel); - -reactPanel.setEditor(TextPanelEditor); -reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook((options: TextOptions, prevPluginId: string, prevOptions: any) => { - if (prevPluginId === 'text') { - return prevOptions as TextOptions; - } - - return options; -}); +export const reactPanel = new ReactPanelPlugin(TextPanel) + .setDefaults(defaults) + .setEditor(TextPanelEditor) + .setPanelChangeHandler((options: TextOptions, prevPluginId: string, prevOptions: any) => { + if (prevPluginId === 'text') { + return prevOptions as TextOptions; + } + return options; + });