diff --git a/public/app/app.ts b/public/app/app.ts index c50432eb23b..a4d6c73c908 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,7 +41,7 @@ import { configureStore } from './store/configureStore'; import { AppWrapper } from './AppWrapper'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { AngularApp } from './angular/AngularApp'; -import { PanelRenderer } from './features/panel/PanelRenderer'; +import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { QueryRunner } from './features/query/state/QueryRunner'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl'; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 536d02e8f16..77ed750e0e3 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -15,6 +15,7 @@ import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; +import panelsReducers from 'app/features/panel/state/reducers'; const rootReducers = { ...sharedReducers, @@ -32,6 +33,7 @@ const rootReducers = { ...ldapReducers, ...templatingReducers, ...importDashboardReducers, + ...panelsReducers, }; const addedReducers = {}; diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index c81962bcf5e..fc1965128d3 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -108,7 +108,7 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise void; @@ -197,11 +198,7 @@ class UnConnectedAlertTab extends PureComponent { return ( - this.panelCtrl?.refresh()} - /> + this.panelCtrl?.refresh()} /> ); }; @@ -263,7 +260,7 @@ class UnConnectedAlertTab extends PureComponent { const mapStateToProps: MapStateToProps = (state, props) => { return { - angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent, + angularPanelComponent: getPanelStateForModel(state, props.panel)?.angularComponent, }; }; diff --git a/public/app/features/alerting/TestRuleResult.tsx b/public/app/features/alerting/TestRuleResult.tsx index a9637aa73a9..4a53b3edb3a 100644 --- a/public/app/features/alerting/TestRuleResult.tsx +++ b/public/app/features/alerting/TestRuleResult.tsx @@ -39,7 +39,7 @@ export class TestRuleResult extends PureComponent { // now replace panel to get current edits model.panels = model.panels.map((dashPanel) => { - return dashPanel.id === panel.editSourceId ? panel.getSaveModel() : dashPanel; + return dashPanel.id === panel.id ? panel.getSaveModel() : dashPanel; }); const payload = { dashboard: model, panelId: panel.id }; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index 70f4fdd3492..e025183896a 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -139,10 +139,11 @@ const dashboard = { folderTitle: 'super folder', }, } as DashboardModel; + const panel = ({ datasource: dataSources.prometheus.uid, title: 'mypanel', - editSourceId: 34, + id: 34, targets: [ { expr: 'sum(some_metric [$__interval])) by (app)', @@ -273,11 +274,11 @@ describe('PanelAlertTabContent', () => { expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { dashboardUID: dashboard.uid, - panelId: panel.editSourceId, + panelId: panel.id, }); expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { dashboardUID: dashboard.uid, - panelId: panel.editSourceId, + panelId: panel.id, }); }); }); diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts index 0fdd3db62bc..7bdb4b30a72 100644 --- a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -37,13 +37,13 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option dispatch( fetchPromRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID: dashboard.uid, panelId: panel.editSourceId }, + filter: { dashboardUID: dashboard.uid, panelId: panel.id }, }) ); dispatch( fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID: dashboard.uid, panelId: panel.editSourceId }, + filter: { dashboardUID: dashboard.uid, panelId: panel.id }, }) ); }; @@ -55,7 +55,7 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option }; } return () => {}; - }, [dispatch, poll, panel.editSourceId, dashboard.uid]); + }, [dispatch, poll, panel.id, dashboard.uid]); const loading = promRuleRequest.loading || rulerRuleRequest.loading; const errors = [promRuleRequest.error, rulerRuleRequest.error].filter( @@ -73,7 +73,7 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option .filter( (rule) => rule.annotations[Annotation.dashboardUID] === dashboard.uid && - rule.annotations[Annotation.panelID] === String(panel.editSourceId) + rule.annotations[Annotation.panelID] === String(panel.id) ), [combinedNamespaces, dashboard, panel] ); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 57f1d3bac54..58dd949d139 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -281,7 +281,7 @@ export const panelToRuleFormValues = async ( dashboard: DashboardModel ): Promise | undefined> => { const { targets } = panel; - if (!panel.editSourceId || !dashboard.uid) { + if (!panel.id || !dashboard.uid) { return undefined; } @@ -324,7 +324,7 @@ export const panelToRuleFormValues = async ( }, { key: Annotation.panelID, - value: String(panel.editSourceId), + value: String(panel.id), }, ], }; diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts index 43b47a02dff..b1ea26a0c57 100644 --- a/public/app/features/annotations/event_editor.ts +++ b/public/app/features/annotations/event_editor.ts @@ -19,7 +19,7 @@ export class EventEditorCtrl { constructor() {} $onInit() { - this.event.panelId = this.panelCtrl.panel.editSourceId ?? this.panelCtrl.panel.id; // set correct id if in panel edit + this.event.panelId = this.panelCtrl.panel.id; // set correct id if in panel edit this.event.dashboardId = this.panelCtrl.dashboard.id; // Annotations query returns time as Unix timestamp in milliseconds diff --git a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx index 6d0c05e5859..568149226a1 100644 --- a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx +++ b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx @@ -10,6 +10,7 @@ import { InspectContent } from './InspectContent'; import { useDatasourceMetadata, useInspectTabs } from './hooks'; import { useLocation } from 'react-router-dom'; import { InspectTab } from 'app/features/inspector/types'; +import { getPanelStateForModel } from 'app/features/panel/state/selectors'; interface OwnProps { dashboard: DashboardModel; @@ -63,7 +64,7 @@ const PanelInspectorUnconnected: React.FC = ({ panel, dashboard, plugin } }; const mapStateToProps: MapStateToProps = (state, props) => { - const panelState = state.dashboard.panels[props.panel.id]; + const panelState = getPanelStateForModel(state, props.panel); if (!panelState) { return { plugin: null }; } diff --git a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx index 0a1bcb23984..5ab69063c4b 100644 --- a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx @@ -8,10 +8,11 @@ import { AngularComponent, getAngularLoader } from '@grafana/runtime'; // Types import { PanelModel, DashboardModel } from '../../state'; import { PanelPlugin, PanelPluginMeta } from '@grafana/data'; -import { changePanelPlugin } from '../../state/actions'; +import { changePanelPlugin } from 'app/features/panel/state/actions'; import { StoreState } from 'app/types'; import { getSectionOpenState, saveSectionOpenState } from './state/utils'; import { PanelCtrl } from 'app/features/panel/panel_ctrl'; +import { getPanelStateForModel } from 'app/features/panel/state/selectors'; interface OwnProps { panel: PanelModel; @@ -20,7 +21,7 @@ interface OwnProps { } const mapStateToProps = (state: StoreState, props: OwnProps) => ({ - angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent, + angularPanelComponent: getPanelStateForModel(state, props.panel)?.angularComponent, }); const mapDispatchToProps = { changePanelPlugin }; @@ -41,7 +42,10 @@ export class AngularPanelOptionsUnconnected extends PureComponent { } componentDidUpdate(prevProps: Props) { - if (this.props.plugin !== prevProps.plugin) { + if ( + this.props.plugin !== prevProps.plugin || + this.props.angularPanelComponent !== prevProps.angularPanelComponent + ) { this.cleanUpAngularOptions(); } diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 34f545a0eb5..275a060a1e4 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -34,7 +34,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { toggleTableView } from './state/reducers'; import { getPanelEditorTabs } from './state/selectors'; -import { getPanelStateById } from '../../state/selectors'; import { getVariables } from 'app/features/variables/state/selectors'; import { StoreState } from 'app/types'; @@ -55,6 +54,7 @@ import { import { notifyApp } from '../../../../core/actions'; import { PanelEditorTableView } from './PanelEditorTableView'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; +import { getPanelStateForModel } from 'app/features/panel/state/selectors'; interface OwnProps { dashboard: DashboardModel; @@ -64,12 +64,12 @@ interface OwnProps { const mapStateToProps = (state: StoreState) => { const panel = state.panelEditor.getPanel(); - const { plugin, instanceState } = getPanelStateById(state.dashboard, panel.id); + const panelState = getPanelStateForModel(state, panel); return { - plugin: plugin, panel, - instanceState, + plugin: panelState?.plugin, + instanceState: panelState?.instanceState, initDone: state.panelEditor.initDone, uiState: state.panelEditor.ui, tableViewEnabled: state.panelEditor.tableViewEnabled, @@ -244,8 +244,10 @@ export class PanelEditorUnconnected extends PureComponent { return (
-
+
{ isInView={true} width={panelSize.width} height={panelSize.height} + skipStateCleanUp={true} />
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorTableView.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorTableView.tsx index ccb12b4fd06..3748c712951 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorTableView.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorTableView.tsx @@ -1,5 +1,5 @@ import { PanelChrome } from '@grafana/ui'; -import { PanelRenderer } from 'app/features/panel/PanelRenderer'; +import { PanelRenderer } from 'app/features/panel/components/PanelRenderer'; import React, { useEffect, useState } from 'react'; import { PanelModel, DashboardModel } from '../../state'; import { usePanelLatestData } from './usePanelLatestData'; diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index 61e8bce146e..2799177e7a2 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme, PanelPluginMeta, SelectableValue } from '@grafana/data'; import { Button, CustomScrollbar, Icon, Input, RadioButtonGroup, useStyles } from '@grafana/ui'; -import { changePanelPlugin } from '../../state/actions'; +import { changePanelPlugin } from '../../../panel/state/actions'; import { PanelModel } from '../../state/PanelModel'; import { useDispatch, useSelector } from 'react-redux'; import { filterPluginList, getAllPanelPluginMeta, VizTypePicker } from '../VizTypePicker/VizTypePicker'; diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts index d142187c601..a41ece9b4de 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts @@ -1,7 +1,7 @@ import { thunkTester } from '../../../../../../test/core/thunk/thunkTester'; import { closeEditor, initialState, PanelEditorState } from './reducers'; import { exitPanelEditor, initPanelEditor, skipPanelUpdate } from './actions'; -import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers'; +import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers'; import { DashboardModel, PanelModel } from '../../../state'; import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; @@ -14,15 +14,20 @@ describe('panelEditor actions', () => { const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); const dispatchedActions = await thunkTester({ - panelEditorNew: { ...initialState }, + panelEditor: { ...initialState }, + plugins: { + panels: {}, + }, }) .givenThunk(initPanelEditor) .whenThunkIsDispatched(sourcePanel, dashboard); - expect(dispatchedActions.length).toBe(1); - expect(dispatchedActions[0].payload.sourcePanel).toBe(sourcePanel); - expect(dispatchedActions[0].payload.panel).not.toBe(sourcePanel); - expect(dispatchedActions[0].payload.panel.id).not.toBe(sourcePanel.id); + expect(dispatchedActions.length).toBe(2); + expect(dispatchedActions[0].type).toBe(panelModelAndPluginReady.type); + + expect(dispatchedActions[1].payload.sourcePanel).toBe(sourcePanel); + expect(dispatchedActions[1].payload.panel).not.toBe(sourcePanel); + expect(dispatchedActions[1].payload.panel.id).toBe(sourcePanel.id); }); }); @@ -52,8 +57,8 @@ describe('panelEditor actions', () => { .whenThunkIsDispatched(); expect(dispatchedActions.length).toBe(2); - expect(dispatchedActions[0].type).toBe(closeEditor.type); - expect(dispatchedActions[1].type).toBe(cleanUpEditPanel.type); + expect(dispatchedActions[0].type).toBe(cleanUpPanelState.type); + expect(dispatchedActions[1].type).toBe(closeEditor.type); expect(sourcePanel.getOptions()).toEqual({ prop: true }); expect(sourcePanel.id).toEqual(12); }); @@ -140,7 +145,7 @@ describe('panelEditor actions', () => { describe('when called with a panel that is the same as the modified panel', () => { it('then it should return true', () => { const meta: any = {}; - const modified: any = { editSourceId: 14, libraryPanel: { uid: '123', name: 'Name', meta, version: 1 } }; + const modified: any = { id: 14, libraryPanel: { uid: '123', name: 'Name', meta, version: 1 } }; const panel: any = { id: 14, libraryPanel: { uid: '123', name: 'Name', meta, version: 1 } }; expect(skipPanelUpdate(modified, panel)).toEqual(true); diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.ts index 5ee08a09202..f604c29128f 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.ts @@ -8,14 +8,17 @@ import { setPanelEditorUIState, updateEditorInitState, } from './reducers'; -import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers'; +import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers'; import store from 'app/core/store'; import { pick } from 'lodash'; +import { initPanelState } from 'app/features/panel/state/actions'; export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult { - return (dispatch) => { + return async (dispatch) => { const panel = dashboard.initEditPanel(sourcePanel); + await dispatch(initPanelState(panel)); + dispatch( updateEditorInitState({ panel, @@ -60,7 +63,9 @@ export function updateDuplicateLibraryPanels( panel.configRev++; if (pluginChanged) { - dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin: panel.plugin! })); + panel.generateNewKey(); + + dispatch(panelModelAndPluginReady({ key: panel.key, plugin: panel.plugin! })); } // Resend last query result on source panel query runner @@ -85,7 +90,7 @@ export function skipPanelUpdate(modifiedPanel: PanelModel, panelToUpdate: PanelM } // don't update the modifiedPanel twice - if (panelToUpdate.id && panelToUpdate.id === modifiedPanel.editSourceId) { + if (panelToUpdate.id && panelToUpdate.id === modifiedPanel.id) { return true; } @@ -101,27 +106,28 @@ export function exitPanelEditor(): ThunkResult { return async (dispatch, getStore) => { const dashboard = getStore().dashboard.getModel(); const { getPanel, getSourcePanel, shouldDiscardChanges } = getStore().panelEditor; + const panel = getPanel(); + + if (dashboard) { + dashboard.exitPanelEditor(); + } if (!shouldDiscardChanges) { - const panel = getPanel(); const modifiedSaveModel = panel.getSaveModel(); const sourcePanel = getSourcePanel(); const panelTypeChanged = sourcePanel.type !== panel.type; dispatch(updateDuplicateLibraryPanels(panel, dashboard)); - // restore the source panel ID before we update source panel - modifiedSaveModel.id = sourcePanel.id; - sourcePanel.restoreModel(modifiedSaveModel); sourcePanel.configRev++; // force check the configs - // Loaded plugin is not included in the persisted properties - // So is not handled by restoreModel - sourcePanel.plugin = panel.plugin; - if (panelTypeChanged) { - await dispatch(panelModelAndPluginReady({ panelId: sourcePanel.id, plugin: panel.plugin! })); + // Loaded plugin is not included in the persisted properties so is not handled by restoreModel + sourcePanel.plugin = panel.plugin; + sourcePanel.generateNewKey(); + + await dispatch(panelModelAndPluginReady({ key: sourcePanel.key, plugin: panel.plugin! })); } // Resend last query result on source panel query runner @@ -132,12 +138,8 @@ export function exitPanelEditor(): ThunkResult { }, 20); } - if (dashboard) { - dashboard.exitPanelEditor(); - } - + dispatch(cleanUpPanelState({ key: panel.key })); dispatch(closeEditor()); - dispatch(cleanUpEditPanel()); }; } diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 961490977ff..042b759f401 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -23,6 +23,12 @@ jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', return { GeneralSettings }; }); +jest.mock('app/features/query/components/QueryGroup', () => { + return { + QueryGroup: () => null, + }; +}); + jest.mock('app/core/core', () => ({ appEvents: { subscribe: () => { diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 49e28929b33..315808283bd 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -94,6 +94,7 @@ export class SoloPanelPage extends Component { } return ( { isViewing={panel.isViewing} > {(width: number, height: number) => { - return this.renderPanel(panel, width, height, panel.key); + return this.renderPanel(panel, width, height); }} ); @@ -188,18 +188,19 @@ export class DashboardGrid extends PureComponent { return panelElements; } - renderPanel(panel: PanelModel, width: any, height: any, itemKey: string) { + renderPanel(panel: PanelModel, width: any, height: any) { if (panel.type === 'row') { - return ; + return ; } if (panel.type === 'add-panel') { - return ; + return ; } return ( { - const panelState = state.dashboard.panels[props.panel.id]; + const panelState = state.panels[props.stateKey]; if (!panelState) { return { plugin: null }; } @@ -41,7 +37,8 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { }; const mapDispatchToProps = { - initDashboardPanel, + initPanelState, + cleanUpPanelState, }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -60,7 +57,16 @@ export class DashboardPanelUnconnected extends PureComponent { } componentDidMount() { - this.props.initDashboardPanel(this.props.panel); + if (!this.props.plugin) { + this.props.initPanelState(this.props.panel); + } + } + + componentWillUnmount() { + // Most of the time an unmount should result in cleanup but in PanelEdit it should not + if (!this.props.skipStateCleanUp) { + this.props.cleanUpPanelState({ key: this.props.stateKey }); + } } componentDidUpdate() { diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 75f8e09428e..4892a66e113 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { Subscription } from 'rxjs'; import { locationService } from '@grafana/runtime'; @@ -37,7 +37,7 @@ import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annota import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { liveTimer } from './liveTimer'; import { isSoloRoute } from '../../../routes/utils'; -import { setPanelInstanceState } from '../state/reducers'; +import { setPanelInstanceState } from '../../panel/state/reducers'; import { store } from 'app/store/store'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -63,7 +63,7 @@ export interface State { liveTime?: TimeRange; } -export class PanelChrome extends Component { +export class PanelChrome extends PureComponent { private readonly timeSrv: TimeSrv = getTimeSrv(); private subs = new Subscription(); private eventFilter: EventFilterOptions = { onlyLocal: true }; @@ -103,7 +103,7 @@ export class PanelChrome extends Component { }); // Set redux panel state so panel options can get notified - store.dispatch(setPanelInstanceState({ panelId: this.props.panel.id, value })); + store.dispatch(setPanelInstanceState({ key: this.props.panel.key, value })); }; getPanelContextApp() { @@ -221,19 +221,6 @@ export class PanelChrome extends Component { } } - shouldComponentUpdate(prevProps: Props, prevState: State) { - const { plugin, panel } = this.props; - - // If plugin changed we need to process fieldOverrides again - // We do this by asking panel query runner to resend last result - if (prevProps.plugin !== plugin) { - panel.getQueryRunner().resendLastResult(); - return false; - } - - return true; - } - // Updates the response with information from the stream // The next is outside a react synthetic event so setState is not batched // So in this context we can only do a single call to setState diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index 6d07465e37b..3d82607222b 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -8,12 +8,13 @@ import { selectors } from '@grafana/e2e-selectors'; import { PanelHeader } from './PanelHeader/PanelHeader'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; -import { setPanelAngularComponent } from '../state/reducers'; +import { setPanelAngularComponent } from 'app/features/panel/state/reducers'; import config from 'app/core/config'; import { DashboardModel, PanelModel } from '../state'; import { StoreState } from 'app/types'; import { PANEL_BORDER } from 'app/core/constants'; import { isSoloRoute } from '../../../routes/utils'; +import { getPanelStateForModel } from 'app/features/panel/state/selectors'; interface OwnProps { panel: PanelModel; @@ -27,7 +28,7 @@ interface OwnProps { } interface ConnectedProps { - angularComponent?: AngularComponent | null; + angularComponent?: AngularComponent; } interface DispatchProps { @@ -98,7 +99,6 @@ export class PanelChromeAngularUnconnected extends PureComponent { } componentWillUnmount() { - this.cleanUpAngularPanel(); this.subs.unsubscribe(); } @@ -106,7 +106,6 @@ export class PanelChromeAngularUnconnected extends PureComponent { const { plugin, height, width, panel } = this.props; if (prevProps.plugin !== plugin) { - this.cleanUpAngularPanel(); this.loadAngularPanel(); } @@ -154,21 +153,11 @@ export class PanelChromeAngularUnconnected extends PureComponent { }; setPanelAngularComponent({ - panelId: panel.id, + key: panel.key, angularComponent: loader.load(this.element, this.scopeProps, template), }); } - cleanUpAngularPanel() { - const { angularComponent, setPanelAngularComponent, panel } = this.props; - - if (angularComponent) { - angularComponent.destroy(); - } - - setPanelAngularComponent({ panelId: panel.id, angularComponent: null }); - } - hasOverlayHeader() { const { panel } = this.props; const { data } = this.state; @@ -226,7 +215,7 @@ export class PanelChromeAngularUnconnected extends PureComponent { const mapStateToProps: MapStateToProps = (state, props) => { return { - angularComponent: state.dashboard.panels[props.panel.id].angularComponent, + angularComponent: getPanelStateForModel(state, props.panel)?.angularComponent, }; }; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx index 4fd29502f92..b3b2afe1ab2 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx @@ -5,6 +5,7 @@ import { PanelMenuItem } from '@grafana/data'; import { DashboardModel, PanelModel } from '../../state'; import { StoreState } from '../../../../types'; import { getPanelMenu } from '../../utils/getPanelMenu'; +import { getPanelStateForModel } from 'app/features/panel/state/selectors'; interface PanelHeaderMenuProviderApi { items: PanelMenuItem[]; @@ -18,9 +19,8 @@ interface Props { export const PanelHeaderMenuProvider: FC = ({ panel, dashboard, children }) => { const [items, setItems] = useState([]); - const angularComponent = useSelector( - (state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null - ); + const angularComponent = useSelector((state: StoreState) => getPanelStateForModel(state, panel)?.angularComponent); + useEffect(() => { setItems(getPanelMenu(dashboard, panel, angularComponent)); }, [dashboard, panel, angularComponent, setItems]); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index e49a875eab2..48fac9fe67f 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -285,11 +285,8 @@ export class DashboardModel { .map((panel: PanelModel) => { // If we save while editing we should include the panel in edit mode instead of the // unmodified source panel - if (this.panelInEdit && this.panelInEdit.editSourceId === panel.id) { - const saveModel = this.panelInEdit.getSaveModel(); - // while editing a panel we modify its id, need to restore it here - saveModel.id = this.panelInEdit.editSourceId; - return saveModel; + if (this.panelInEdit && this.panelInEdit.id === panel.id) { + return this.panelInEdit.getSaveModel(); } return panel.getSaveModel(); diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index f95bb7ca598..39c5145b44f 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -265,7 +265,6 @@ describe('PanelModel', () => { }); }); - model.editSourceId = 1001; model.fieldConfig.defaults.decimals = 3; model.fieldConfig.defaults.custom = { customProp: true, @@ -289,10 +288,6 @@ describe('PanelModel', () => { model.alert = { id: 2 }; }); - it('should keep editSourceId', () => { - expect(model.editSourceId).toBe(1001); - }); - it('should keep maxDataPoints', () => { expect(model.maxDataPoints).toBe(100); }); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 8fdf904069b..5756fa021cf 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -1,5 +1,6 @@ // Libraries import { cloneDeep, defaultsDeep, isArray, isEqual, keys } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; // Utils import { getTemplateSrv } from '@grafana/runtime'; import { getNextRefIdChar } from 'app/core/utils/query'; @@ -20,7 +21,6 @@ import { PanelModel as IPanelModel, DatasourceRef, } from '@grafana/data'; -import { EDIT_PANEL_ID } from 'app/core/constants'; import config from 'app/core/config'; import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { @@ -61,7 +61,6 @@ const notPersistedProperties: { [str: string]: boolean } = { plugin: true, queryRunner: true, replaceVariables: true, - editSourceId: true, configRev: true, getDisplayTitle: true, dataSupport: true, @@ -104,13 +103,13 @@ const mustKeepProps: { [str: string]: boolean } = { queryRunner: true, transformations: true, fieldConfig: true, - editSourceId: true, maxDataPoints: true, interval: true, replaceVariables: true, libraryPanel: true, getDisplayTitle: true, configRev: true, + key: true, }; const defaults: any = { @@ -130,7 +129,6 @@ const defaults: any = { export class PanelModel implements DataConfigSource, IPanelModel { /* persisted id, used in URL to identify a panel */ id!: number; - editSourceId?: number; gridPos!: GridPos; type!: string; title!: string; @@ -178,7 +176,11 @@ export class PanelModel implements DataConfigSource, IPanelModel { cachedPluginOptions: Record = {}; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; plugin?: PanelPlugin; - key: string; // unique in dashboard, changes will force a react reload + /** + * Unique in application state, this is used as redux key for panel and for redux panel state + * Change will cause unmount and re-init of panel + */ + key: string; /** * The PanelModel event bus only used for internal and legacy angular support. @@ -192,7 +194,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { this.events = new EventBusSrv(); this.restoreModel(model); this.replaceVariables = this.replaceVariables.bind(this); - this.key = this.id ? `${this.id}` : `panel-${Math.floor(Math.random() * 100000)}`; + this.key = uuidv4(); } /** Given a persistened PanelModel restores property values */ @@ -230,6 +232,10 @@ export class PanelModel implements DataConfigSource, IPanelModel { this.ensureQueryIds(); } + generateNewKey() { + this.key = uuidv4(); + } + ensureQueryIds() { if (this.targets && isArray(this.targets)) { for (const query of this.targets) { @@ -303,7 +309,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { this.getQueryRunner().run({ datasource: this.datasource, queries: this.targets, - panelId: this.editSourceId || this.id, + panelId: this.id, dashboardId: dashboardId, timezone: dashboardTimezone, timeRange: timeData.timeRange, @@ -475,12 +481,9 @@ export class PanelModel implements DataConfigSource, IPanelModel { getEditClone() { const sourceModel = this.getSaveModel(); - // Temporary id for the clone, restored later in redux action when changes are saved - sourceModel.id = EDIT_PANEL_ID; - sourceModel.editSourceId = this.id; - const clone = new PanelModel(sourceModel); clone.isEditing = true; + const sourceQueryRunner = this.getQueryRunner(); // Copy last query result @@ -590,14 +593,6 @@ export class PanelModel implements DataConfigSource, IPanelModel { this.getQueryRunner().resendLastResult(); } - /* - * Panel have a different id while in edit mode (to more easily be able to discard changes) - * Use this to always get the underlying source id - * */ - getSavedId(): number { - return this.editSourceId ?? this.id; - } - /* * This is the title used when displaying the title in the UI so it will include any interpolated variables. * If you need the raw title without interpolation use title property instead. diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 4c532f8aa84..c0b69a9f40b 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -3,20 +3,12 @@ import { getBackendSrv } from '@grafana/runtime'; import { createSuccessNotification } from 'app/core/copy/appNotification'; // Actions import { loadPluginDashboards } from '../../plugins/state/actions'; -import { - cleanUpDashboard, - loadDashboardPermissions, - panelModelAndPluginReady, - setPanelAngularComponent, -} from './reducers'; +import { cleanUpDashboard, loadDashboardPermissions } from './reducers'; import { notifyApp } from 'app/core/actions'; -import { loadPanelPlugin } from 'app/features/plugins/state/actions'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; // Types import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel, ThunkResult } from 'app/types'; -import { PanelModel } from './PanelModel'; import { cancelVariables } from '../../variables/state/actions'; -import { getPanelPluginNotFound } from '../dashgrid/PanelPluginError'; import { getTimeSrv } from '../services/TimeSrv'; import { TimeZone } from '@grafana/data'; @@ -121,55 +113,6 @@ export function removeDashboard(uri: string): ThunkResult { }; } -export function initDashboardPanel(panel: PanelModel): ThunkResult { - return async (dispatch, getStore) => { - let pluginToLoad = panel.type; - let plugin = getStore().plugins.panels[pluginToLoad]; - - if (!plugin) { - try { - plugin = await dispatch(loadPanelPlugin(pluginToLoad)); - } catch (e) { - // When plugin not found - plugin = getPanelPluginNotFound(pluginToLoad, pluginToLoad === 'row'); - } - } - - if (!panel.plugin) { - panel.pluginLoaded(plugin); - } - - dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); - }; -} - -export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult { - return async (dispatch, getStore) => { - // ignore action is no change - if (panel.type === pluginId) { - return; - } - - const store = getStore(); - let plugin = store.plugins.panels[pluginId]; - - if (!plugin) { - 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); - - dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); - }; -} - export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch, getStore) => { const store = getStore(); const dashboard = store.dashboard.getModel(); diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index 2674b32ab38..56e659c7770 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -51,11 +51,6 @@ describe('dashboard reducer', () => { it('should set reset isInitSlow', async () => { expect(state.isInitSlow).toBe(false); }); - - it('should create panel state', async () => { - expect(state.panels['1']).toBeDefined(); - expect(state.panels['2']).toBeDefined(); - }); }); describe('dashboardInitFailed', () => { diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 13482b58eba..fe8f3629af1 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,14 +1,12 @@ -import { createSlice, PayloadAction, Draft } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DashboardAclDTO, DashboardInitError, DashboardInitPhase, DashboardState, - PanelState, QueriesToUpdateOnDashboardLoad, } from 'app/types'; import { AngularComponent } from '@grafana/runtime'; -import { EDIT_PANEL_ID } from 'app/core/constants'; import { processAclItems } from 'app/core/utils/acl'; import { panelEditorReducer } from '../components/PanelEditor/state/reducers'; import { DashboardModel } from './DashboardModel'; @@ -21,7 +19,6 @@ export const initialState: DashboardState = { getModel: () => null, permissions: [], modifiedQueries: null, - panels: {}, initError: null, }; @@ -45,12 +42,6 @@ const dashbardSlice = createSlice({ state.getModel = () => action.payload; state.initPhase = DashboardInitPhase.Completed; state.isInitSlow = false; - - for (const panel of action.payload.panels) { - state.panels[panel.id] = { - pluginId: panel.type, - }; - } }, dashboardInitFailed: (state, action: PayloadAction) => { state.initPhase = DashboardInitPhase.Failed; @@ -60,7 +51,6 @@ const dashbardSlice = createSlice({ }; }, cleanUpDashboard: (state) => { - state.panels = {}; state.initPhase = DashboardInitPhase.NotStarted; state.isInitSlow = false; state.initError = null; @@ -72,32 +62,12 @@ const dashbardSlice = createSlice({ clearDashboardQueriesToUpdateOnLoad: (state) => { state.modifiedQueries = null; }, - panelModelAndPluginReady: (state, action: PayloadAction) => { - updatePanelState(state, action.payload.panelId, { plugin: action.payload.plugin }); - }, - cleanUpEditPanel: (state) => { - delete state.panels[EDIT_PANEL_ID]; - }, - setPanelInstanceState: (state, action: PayloadAction) => { - updatePanelState(state, action.payload.panelId, { instanceState: action.payload.value }); - }, - setPanelAngularComponent: (state, action: PayloadAction) => { - updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent }); - }, addPanel: (state, action: PayloadAction) => { - state.panels[action.payload.id] = { pluginId: action.payload.type }; + //state.panels[action.payload.id] = { pluginId: action.payload.type }; }, }, }); -export function updatePanelState(state: Draft, panelId: number, ps: Partial) { - if (!state.panels[panelId]) { - state.panels[panelId] = ps as PanelState; - } else { - Object.assign(state.panels[panelId], ps); - } -} - export interface PanelModelAndPluginReadyPayload { panelId: number; plugin: PanelPlugin; @@ -123,11 +93,7 @@ export const { cleanUpDashboard, setDashboardQueriesToUpdateOnLoad, clearDashboardQueriesToUpdateOnLoad, - panelModelAndPluginReady, addPanel, - cleanUpEditPanel, - setPanelAngularComponent, - setPanelInstanceState, } = dashbardSlice.actions; export const dashboardReducer = dashbardSlice.reducer; diff --git a/public/app/features/dashboard/state/selectors.ts b/public/app/features/dashboard/state/selectors.ts index 1f788162663..01acd870b3c 100644 --- a/public/app/features/dashboard/state/selectors.ts +++ b/public/app/features/dashboard/state/selectors.ts @@ -1,14 +1,6 @@ -import { DashboardState, PanelState, StoreState } from 'app/types'; +import { StoreState } from 'app/types'; import { PanelPlugin } from '@grafana/data'; -import { getPanelPluginNotFound } from '../dashgrid/PanelPluginError'; - -export function getPanelStateById(state: DashboardState, panelId: number): PanelState { - if (!panelId) { - return {} as PanelState; - } - - return state.panels[panelId] ?? ({} as PanelState); -} +import { getPanelPluginNotFound } from '../../panel/components/PanelPluginError'; export const getPanelPluginWithFallback = (panelType: string) => (state: StoreState): PanelPlugin => { const plugin = state.plugins.panels[panelType]; diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx index 0ce52014bce..6401c734c28 100644 --- a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -6,7 +6,7 @@ import { LibraryElementDTO } from '../../types'; import { PanelTypeCard } from 'app/features/dashboard/components/VizTypePicker/PanelTypeCard'; import { DeleteLibraryPanelModal } from '../DeleteLibraryPanelModal/DeleteLibraryPanelModal'; import { config } from '@grafana/runtime'; -import { getPanelPluginNotFound } from 'app/features/dashboard/dashgrid/PanelPluginError'; +import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; export interface LibraryPanelCardProps { libraryPanel: LibraryElementDTO; diff --git a/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx b/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx index 15f3f6af965..c39c5b7beaf 100644 --- a/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx +++ b/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx @@ -7,10 +7,8 @@ import { Button, useStyles2, VerticalGroup } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/state'; import { AddLibraryPanelModal } from '../AddLibraryPanelModal/AddLibraryPanelModal'; import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView'; -import { PanelDirectiveReadyEvent, PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events'; import { LibraryElementDTO } from '../../types'; -import { toPanelModelLibraryPanel } from '../../utils'; -import { changePanelPlugin } from 'app/features/dashboard/state/actions'; +import { changeToLibraryPanel } from 'app/features/panel/state/actions'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { ChangeLibraryPanelModal } from '../ChangeLibraryPanelModal/ChangeLibraryPanelModal'; import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/PanelTypeFilter'; @@ -38,29 +36,10 @@ export const PanelLibraryOptionsGroup: FC = ({ panel, searchQuery }) => { if (!changeToPanel) { return; } + setChangeToPanel(undefined); - const panelTypeChanged = panel.type !== changeToPanel.model.type; - - if (panelTypeChanged) { - await dispatch(changePanelPlugin(panel, changeToPanel.model.type)); - } - - panel.restoreModel({ - ...changeToPanel.model, - gridPos: panel.gridPos, - id: panel.id, - libraryPanel: toPanelModelLibraryPanel(changeToPanel), - }); - - panel.configRev = 0; - panel.refresh(); - const unsubscribeEvent = panel.events.subscribe(PanelDirectiveReadyEvent, () => { - panel.refresh(); - unsubscribeEvent.unsubscribe(); - }); - panel.events.publish(PanelQueriesChangedEvent); - panel.events.publish(PanelOptionsChangedEvent); + dispatch(changeToLibraryPanel(panel, changeToPanel)); }; const onAddToPanelLibrary = () => { diff --git a/public/app/features/dashboard/dashgrid/PanelPluginError.tsx b/public/app/features/panel/components/PanelPluginError.tsx similarity index 100% rename from public/app/features/dashboard/dashgrid/PanelPluginError.tsx rename to public/app/features/panel/components/PanelPluginError.tsx diff --git a/public/app/features/panel/PanelRenderer.tsx b/public/app/features/panel/components/PanelRenderer.tsx similarity index 96% rename from public/app/features/panel/PanelRenderer.tsx rename to public/app/features/panel/components/PanelRenderer.tsx index e3573a419ab..c1358f94139 100644 --- a/public/app/features/panel/PanelRenderer.tsx +++ b/public/app/features/panel/components/PanelRenderer.tsx @@ -3,10 +3,12 @@ import { applyFieldOverrides, FieldConfigSource, getTimeZone, PanelData, PanelPl import { PanelRendererProps } from '@grafana/runtime'; import { appEvents } from 'app/core/core'; import { useAsync } from 'react-use'; -import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults'; -import { importPanelPlugin } from '../plugins/plugin_loader'; +import { getPanelOptionsWithDefaults, OptionDefaults } from '../../dashboard/state/getPanelOptionsWithDefaults'; +import { importPanelPlugin } from '../../plugins/importPanelPlugin'; import { useTheme2 } from '@grafana/ui'; + const defaultFieldConfig = { defaults: {}, overrides: [] }; + export function PanelRenderer

(props: PanelRendererProps) { const { pluginId, diff --git a/public/app/features/panel/state/actions.test.ts b/public/app/features/panel/state/actions.test.ts new file mode 100644 index 00000000000..fcd5fba6daf --- /dev/null +++ b/public/app/features/panel/state/actions.test.ts @@ -0,0 +1,39 @@ +import { PanelModel } from 'app/features/dashboard/state'; +import { thunkTester } from '../../../../test/core/thunk/thunkTester'; +import { changePanelPlugin } from './actions'; +import { panelModelAndPluginReady } from './reducers'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; + +jest.mock('app/features/plugins/importPanelPlugin', () => { + return { + importPanelPlugin: function () { + return Promise.resolve( + getPanelPlugin({ + id: 'table', + }) + ); + }, + }; +}); + +describe('panel state actions', () => { + describe('changePanelPlugin', () => { + it('Should load plugin and call changePlugin', async () => { + const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); + + const dispatchedActions = await thunkTester({ + plugins: { + panels: {}, + }, + panels: {}, + }) + .givenThunk(changePanelPlugin) + .whenThunkIsDispatched(sourcePanel, 'table'); + + expect(dispatchedActions.length).toBe(2); + expect(dispatchedActions[0].type).toBe('plugins/loadPanelPlugin/fulfilled'); + expect(dispatchedActions[1].type).toBe(panelModelAndPluginReady.type); + expect(sourcePanel.type).toBe('table'); + }); + }); +}); diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts new file mode 100644 index 00000000000..d1374145061 --- /dev/null +++ b/public/app/features/panel/state/actions.ts @@ -0,0 +1,94 @@ +import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { loadPanelPlugin } from 'app/features/plugins/state/actions'; +import { ThunkResult } from 'app/types'; +import { panelModelAndPluginReady } from './reducers'; +import { LibraryElementDTO } from 'app/features/library-panels/types'; +import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils'; +import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events'; + +export function initPanelState(panel: PanelModel): ThunkResult { + return async (dispatch, getStore) => { + let pluginToLoad = panel.type; + let plugin = getStore().plugins.panels[pluginToLoad]; + + if (!plugin) { + try { + plugin = await dispatch(loadPanelPlugin(pluginToLoad)); + } catch (e) { + // When plugin not found + plugin = getPanelPluginNotFound(pluginToLoad, pluginToLoad === 'row'); + } + } + + if (!panel.plugin) { + panel.pluginLoaded(plugin); + } + + dispatch(panelModelAndPluginReady({ key: panel.key, plugin })); + }; +} + +export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult { + return async (dispatch, getStore) => { + // ignore action is no change + if (panel.type === pluginId) { + return; + } + + const store = getStore(); + let plugin = store.plugins.panels[pluginId]; + + if (!plugin) { + plugin = await dispatch(loadPanelPlugin(pluginId)); + } + + const oldKey = panel.key; + + panel.changePlugin(plugin); + panel.generateNewKey(); + + dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey: oldKey })); + }; +} + +export function changeToLibraryPanel(panel: PanelModel, libraryPanel: LibraryElementDTO): ThunkResult { + return async (dispatch, getStore) => { + const newPluginId = libraryPanel.model.type; + const oldType = panel.type; + + // Update model but preserve gridPos & id + panel.restoreModel({ + ...libraryPanel.model, + gridPos: panel.gridPos, + id: panel.id, + libraryPanel: toPanelModelLibraryPanel(libraryPanel.model), + }); + + // a new library panel usually means new queries, clear any current result + panel.getQueryRunner().clearLastResult(); + + // Handle plugin change + if (oldType !== newPluginId) { + const store = getStore(); + let plugin = store.plugins.panels[newPluginId]; + + if (!plugin) { + plugin = await dispatch(loadPanelPlugin(newPluginId)); + } + + const oldKey = panel.key; + + panel.pluginLoaded(plugin); + panel.generateNewKey(); + + await dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey: oldKey })); + } + + panel.configRev = 0; + panel.refresh(); + + panel.events.publish(PanelQueriesChangedEvent); + panel.events.publish(PanelOptionsChangedEvent); + }; +} diff --git a/public/app/features/panel/state/reducers.ts b/public/app/features/panel/state/reducers.ts new file mode 100644 index 00000000000..19341a53d20 --- /dev/null +++ b/public/app/features/panel/state/reducers.ts @@ -0,0 +1,78 @@ +import { createSlice, Draft, PayloadAction } from '@reduxjs/toolkit'; +import { AngularComponent } from '@grafana/runtime'; +import { PanelPlugin } from '@grafana/data'; + +export type RootPanelsState = Record; + +export interface PanelState { + plugin?: PanelPlugin; + angularComponent?: AngularComponent; + instanceState?: any | null; +} + +export const initialState: RootPanelsState = {}; + +const panelsSlice = createSlice({ + name: 'panels', + initialState, + reducers: { + panelModelAndPluginReady: (state, action: PayloadAction) => { + if (action.payload.cleanUpKey) { + cleanUpAngularComponent(state[action.payload.cleanUpKey]); + delete state[action.payload.cleanUpKey]; + } + + state[action.payload.key] = { + plugin: action.payload.plugin, + }; + }, + cleanUpPanelState: (state, action: PayloadAction<{ key: string }>) => { + cleanUpAngularComponent(state[action.payload.key]); + delete state[action.payload.key]; + }, + setPanelInstanceState: (state, action: PayloadAction) => { + state[action.payload.key].instanceState = action.payload.value; + }, + setPanelAngularComponent: (state, action: PayloadAction) => { + const panelState = state[action.payload.key]; + cleanUpAngularComponent(panelState); + panelState.angularComponent = action.payload.angularComponent; + }, + }, +}); + +function cleanUpAngularComponent(panelState?: Draft) { + if (panelState?.angularComponent) { + panelState.angularComponent.destroy(); + } +} + +export interface PanelModelAndPluginReadyPayload { + key: string; + plugin: PanelPlugin; + /** Used to cleanup previous state when we change key (used when switching panel plugin) */ + cleanUpKey?: string; +} + +export interface SetPanelAngularComponentPayload { + key: string; + angularComponent: AngularComponent; +} + +export interface SetPanelInstanceStatePayload { + key: string; + value: any; +} + +export const { + panelModelAndPluginReady, + setPanelAngularComponent, + setPanelInstanceState, + cleanUpPanelState, +} = panelsSlice.actions; + +export const panelsReducer = panelsSlice.reducer; + +export default { + panels: panelsReducer, +}; diff --git a/public/app/features/panel/state/selectors.ts b/public/app/features/panel/state/selectors.ts new file mode 100644 index 00000000000..af4aac6faee --- /dev/null +++ b/public/app/features/panel/state/selectors.ts @@ -0,0 +1,7 @@ +import { PanelModel } from 'app/features/dashboard/state'; +import { StoreState } from 'app/types'; +import { PanelState } from './reducers'; + +export function getPanelStateForModel(state: StoreState, model: PanelModel): PanelState | undefined { + return state.panels[model.key]; +} diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index 87e635681b0..83cf0e260e1 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -24,7 +24,8 @@ import { Alert, LinkButton, PluginSignatureBadge, Tooltip, Badge, useStyles2, Ic import Page from 'app/core/components/Page/Page'; import { getPluginSettings } from './PluginSettingsCache'; -import { importAppPlugin, importDataSourcePlugin, importPanelPluginFromMeta } from './plugin_loader'; +import { importAppPlugin, importDataSourcePlugin } from './plugin_loader'; +import { importPanelPluginFromMeta } from './importPanelPlugin'; import { getNotFoundNav } from 'app/core/nav_model_srv'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import { AppConfigCtrlWrapper } from './wrappers/AppConfigWrapper'; diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 65f60d7e13a..0cd50413617 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -2,7 +2,7 @@ import { createAsyncThunk, Update } from '@reduxjs/toolkit'; import { getBackendSrv } from '@grafana/runtime'; import { PanelPlugin } from '@grafana/data'; import { StoreState, ThunkResult } from 'app/types'; -import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; +import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { getRemotePlugins, getPluginErrors, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts new file mode 100644 index 00000000000..89eda072e2a --- /dev/null +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -0,0 +1,53 @@ +import config from 'app/core/config'; +import * as grafanaData from '@grafana/data'; +import { getPanelPluginLoadError } from '../panel/components/PanelPluginError'; +import { importPluginModule } from './plugin_loader'; + +interface PanelCache { + [key: string]: Promise; +} +const panelCache: PanelCache = {}; + +export function importPanelPlugin(id: string): Promise { + const loaded = panelCache[id]; + if (loaded) { + return loaded; + } + + const meta = config.panels[id]; + + if (!meta) { + throw new Error(`Plugin ${id} not found`); + } + + panelCache[id] = getPanelPlugin(meta); + + return panelCache[id]; +} + +export function importPanelPluginFromMeta(meta: grafanaData.PanelPluginMeta): Promise { + return getPanelPlugin(meta); +} + +function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise { + return importPluginModule(meta.module) + .then((pluginExports) => { + if (pluginExports.plugin) { + return pluginExports.plugin as grafanaData.PanelPlugin; + } else if (pluginExports.PanelCtrl) { + const plugin = new grafanaData.PanelPlugin(null); + plugin.angularPanelCtrl = pluginExports.PanelCtrl; + return plugin; + } + throw new Error('missing export: plugin or PanelCtrl'); + }) + .then((plugin) => { + plugin.meta = meta; + return plugin; + }) + .catch((err) => { + // TODO, maybe a different error plugin + console.warn('Error loading panel plugin: ' + meta.id, err); + return getPanelPluginLoadError(meta, err); + }); +} diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts index ac03681fc7f..dcbb39d7967 100644 --- a/public/app/features/plugins/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -5,7 +5,8 @@ import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import { DataSourceApi, PanelEvents } from '@grafana/data'; -import { importPanelPlugin, importDataSourcePlugin, importAppPlugin } from './plugin_loader'; +import { importDataSourcePlugin, importAppPlugin } from './plugin_loader'; +import { importPanelPlugin } from './importPanelPlugin'; import DatasourceSrv from './datasource_srv'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 160ca5949d5..af4d95a45a6 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -33,6 +33,7 @@ import * as emotion from '@emotion/css'; import * as grafanaData from '@grafana/data'; import * as grafanaUIraw from '@grafana/ui'; import * as grafanaRuntime from '@grafana/runtime'; +import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings'; // Help the 6.4 to 6.5 migration // The base classes were moved from @grafana/ui to @grafana/data @@ -218,55 +219,3 @@ export function importAppPlugin(meta: grafanaData.PluginMeta): Promise; -} -const panelCache: PanelCache = {}; - -export function importPanelPlugin(id: string): Promise { - const loaded = panelCache[id]; - if (loaded) { - return loaded; - } - - const meta = config.panels[id]; - - if (!meta) { - throw new Error(`Plugin ${id} not found`); - } - - panelCache[id] = getPanelPlugin(meta); - - return panelCache[id]; -} - -export function importPanelPluginFromMeta(meta: grafanaData.PanelPluginMeta): Promise { - return getPanelPlugin(meta); -} - -function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise { - return importPluginModule(meta.module) - .then((pluginExports) => { - if (pluginExports.plugin) { - return pluginExports.plugin as grafanaData.PanelPlugin; - } else if (pluginExports.PanelCtrl) { - const plugin = new grafanaData.PanelPlugin(null); - plugin.angularPanelCtrl = pluginExports.PanelCtrl; - return plugin; - } - throw new Error('missing export: plugin or PanelCtrl'); - }) - .then((plugin) => { - plugin.meta = meta; - return plugin; - }) - .catch((err) => { - // TODO, maybe a different error plugin - console.warn('Error loading panel plugin: ' + meta.id, err); - return getPanelPluginLoadError(meta, err); - }); -} diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts index d96d11edddd..dcabcc58b4b 100644 --- a/public/app/features/plugins/state/actions.ts +++ b/public/app/features/plugins/state/actions.ts @@ -2,7 +2,7 @@ import { getBackendSrv } from '@grafana/runtime'; import { PanelPlugin } from '@grafana/data'; import { ThunkResult } from 'app/types'; import { config } from 'app/core/config'; -import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; +import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { loadPanelPlugin as loadPanelPluginNew, loadPluginDashboards as loadPluginDashboardsNew, diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index b2c885357a9..bd78bc1abb0 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -257,8 +257,7 @@ export class PanelQueryRunner { if (dataSupport.alertStates || dataSupport.annotations) { const panel = (this.dataConfigSource as unknown) as PanelModel; - const id = panel.editSourceId ?? panel.id; - panelData = mergePanelAndDashData(observable, getDashboardQueryRunner().getResult(id)); + panelData = mergePanelAndDashData(observable, getDashboardQueryRunner().getResult(panel.id)); } this.subscription = panelData.subscribe({ @@ -292,6 +291,12 @@ export class PanelQueryRunner { } }; + clearLastResult() { + this.lastResult = undefined; + // A new subject is also needed since it's a replay subject that remembers/sends last value + this.subject = new ReplaySubject(1); + } + /** * Called when the panel is closed */ diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 09eb61c6811..6d067f033fb 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -19,7 +19,7 @@ import { DataWarning, GraphFieldConfig, GraphPanelOptions } from './types'; import { auto } from 'angular'; import { getLocationSrv } from '@grafana/runtime'; import { getDataTimeRange } from './utils'; -import { changePanelPlugin } from 'app/features/dashboard/state/actions'; +import { changePanelPlugin } from 'app/features/panel/state/actions'; import { dispatch } from 'app/store/store'; import { ThresholdMapper } from 'app/features/alerting/state/ThresholdMapper'; import { appEvents } from '../../../core/core'; diff --git a/public/app/plugins/sdk.ts b/public/app/plugins/sdk.ts index 6043f225035..3ab19c61a23 100644 --- a/public/app/plugins/sdk.ts +++ b/public/app/plugins/sdk.ts @@ -3,10 +3,9 @@ import { loadPluginCss } from '@grafana/runtime'; import { PanelCtrl as PanelCtrlES6 } from 'app/features/panel/panel_ctrl'; import { MetricsPanelCtrl as MetricsPanelCtrlES6 } from 'app/features/panel/metrics_panel_ctrl'; import { QueryCtrl as QueryCtrlES6 } from 'app/features/panel/query_ctrl'; -import { alertTab } from 'app/features/alerting/AlertTabCtrl'; const PanelCtrl = makeClassES5Compatible(PanelCtrlES6); const MetricsPanelCtrl = makeClassES5Compatible(MetricsPanelCtrlES6); const QueryCtrl = makeClassES5Compatible(QueryCtrlES6); -export { PanelCtrl, MetricsPanelCtrl, QueryCtrl, alertTab, loadPluginCss }; +export { PanelCtrl, MetricsPanelCtrl, QueryCtrl, loadPluginCss }; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index f1f9304a265..564360f6f60 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,7 +1,6 @@ import { DashboardAcl } from './acl'; -import { DataQuery, PanelPlugin } from '@grafana/data'; +import { DataQuery } from '@grafana/data'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; -import { AngularComponent } from '@grafana/runtime'; export interface DashboardDTO { redirectUri?: string; @@ -75,13 +74,6 @@ export interface QueriesToUpdateOnDashboardLoad { queries: DataQuery[]; } -export interface PanelState { - pluginId: string; - plugin?: PanelPlugin; - angularComponent?: AngularComponent | null; - instanceState?: any | null; -} - export interface DashboardState { getModel: GetMutableDashboardModelFn; initPhase: DashboardInitPhase; @@ -89,5 +81,4 @@ export interface DashboardState { initError: DashboardInitError | null; permissions: DashboardAcl[]; modifiedQueries: QueriesToUpdateOnDashboardLoad | null; - panels: { [id: string]: PanelState }; }