PanelState: Introduce a new separate redux panel state not keyed by panel.id (#40302)

* Initial pass to move panel state to it's own, and make it by key not panel.id

* Progress

* Not making much progress, having panel.key be mutable is causing a lot of issues

* Think this is starting to work

* Began fixing tests

* Add selector

* Bug fixes and changes to cleanup, and fixing all flicking when switching library panels

* Removed console.log

* fixes after merge

* fixing tests

* fixing tests

* Added new test for changePlugin thunk
pull/40100/head
Torkel Ödegaard 4 years ago committed by GitHub
parent 3d9e2d8c82
commit d62ca1283c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      public/app/app.ts
  2. 2
      public/app/core/reducers/root.ts
  3. 2
      public/app/core/utils/explore.ts
  4. 9
      public/app/features/alerting/AlertTab.tsx
  5. 2
      public/app/features/alerting/TestRuleResult.tsx
  6. 7
      public/app/features/alerting/unified/PanelAlertTabContent.test.tsx
  7. 8
      public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts
  8. 4
      public/app/features/alerting/unified/utils/rule-form.ts
  9. 2
      public/app/features/annotations/event_editor.ts
  10. 3
      public/app/features/dashboard/components/Inspector/PanelInspector.tsx
  11. 10
      public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx
  12. 13
      public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
  13. 2
      public/app/features/dashboard/components/PanelEditor/PanelEditorTableView.tsx
  14. 2
      public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx
  15. 23
      public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
  16. 38
      public/app/features/dashboard/components/PanelEditor/state/actions.ts
  17. 6
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  18. 1
      public/app/features/dashboard/containers/SoloPanelPage.tsx
  19. 11
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  20. 28
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  21. 21
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  22. 21
      public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx
  23. 6
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx
  24. 7
      public/app/features/dashboard/state/DashboardModel.ts
  25. 5
      public/app/features/dashboard/state/PanelModel.test.ts
  26. 33
      public/app/features/dashboard/state/PanelModel.ts
  27. 59
      public/app/features/dashboard/state/actions.ts
  28. 5
      public/app/features/dashboard/state/reducers.test.ts
  29. 38
      public/app/features/dashboard/state/reducers.ts
  30. 12
      public/app/features/dashboard/state/selectors.ts
  31. 2
      public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx
  32. 27
      public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx
  33. 0
      public/app/features/panel/components/PanelPluginError.tsx
  34. 6
      public/app/features/panel/components/PanelRenderer.tsx
  35. 39
      public/app/features/panel/state/actions.test.ts
  36. 94
      public/app/features/panel/state/actions.ts
  37. 78
      public/app/features/panel/state/reducers.ts
  38. 7
      public/app/features/panel/state/selectors.ts
  39. 3
      public/app/features/plugins/PluginPage.tsx
  40. 2
      public/app/features/plugins/admin/state/actions.ts
  41. 53
      public/app/features/plugins/importPanelPlugin.ts
  42. 3
      public/app/features/plugins/plugin_component.ts
  43. 53
      public/app/features/plugins/plugin_loader.ts
  44. 2
      public/app/features/plugins/state/actions.ts
  45. 9
      public/app/features/query/state/PanelQueryRunner.ts
  46. 2
      public/app/plugins/panel/graph/module.ts
  47. 3
      public/app/plugins/sdk.ts
  48. 11
      public/app/types/dashboard.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';

@ -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 = {};

@ -108,7 +108,7 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise<strin
};
}
const exploreState = JSON.stringify({ ...state, originPanelId: panel.getSavedId() });
const exploreState = JSON.stringify({ ...state, originPanelId: panel.id });
url = urlUtil.renderUrl('/explore', { left: exploreState });
}

@ -16,6 +16,7 @@ import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelNotSupported } from '../dashboard/components/PanelEditor/PanelNotSupported';
import { AlertState } from '../../plugins/datasource/alertmanager/types';
import { EventBusSrv } from '@grafana/data';
import { getPanelStateForModel } from 'app/features/panel/state/selectors';
interface AngularPanelController {
_enableAlert: () => void;
@ -197,11 +198,7 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
return (
<Modal isOpen={true} icon="history" title="State history" onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<StateHistory
dashboard={dashboard}
panelId={panel.editSourceId ?? panel.id}
onRefresh={() => this.panelCtrl?.refresh()}
/>
<StateHistory dashboard={dashboard} panelId={panel.id} onRefresh={() => this.panelCtrl?.refresh()} />
</Modal>
);
};
@ -263,7 +260,7 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularPanelComponent: state.dashboard.panels[props.panel.id].angularComponent,
angularPanelComponent: getPanelStateForModel(state, props.panel)?.angularComponent,
};
};

@ -39,7 +39,7 @@ export class TestRuleResult extends PureComponent<Props, State> {
// 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 };

@ -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,
});
});
});

@ -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]
);

@ -281,7 +281,7 @@ export const panelToRuleFormValues = async (
dashboard: DashboardModel
): Promise<Partial<RuleFormValues> | 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),
},
],
};

@ -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

@ -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<Props> = ({ panel, dashboard, plugin }
};
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
const panelState = state.dashboard.panels[props.panel.id];
const panelState = getPanelStateForModel(state, props.panel);
if (!panelState) {
return { plugin: null };
}

@ -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<Props> {
}
componentDidUpdate(prevProps: Props) {
if (this.props.plugin !== prevProps.plugin) {
if (
this.props.plugin !== prevProps.plugin ||
this.props.angularPanelComponent !== prevProps.angularPanelComponent
) {
this.cleanUpAngularOptions();
}

@ -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<Props> {
return (
<div className={styles.centeringContainer} style={{ width, height }}>
<div style={panelSize} data-panelid={panel.editSourceId}>
<div style={panelSize} data-panelid={panel.id}>
<DashboardPanel
key={panel.key}
stateKey={panel.key}
dashboard={dashboard}
panel={panel}
isEditing={true}
@ -253,6 +255,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
isInView={true}
width={panelSize.width}
height={panelSize.height}
skipStateCleanUp={true}
/>
</div>
</div>

@ -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';

@ -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';

@ -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);

@ -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<void> {
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<void> {
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<void> {
}, 20);
}
if (dashboard) {
dashboard.exitPanelEditor();
}
dispatch(cleanUpPanelState({ key: panel.key }));
dispatch(closeEditor());
dispatch(cleanUpEditPanel());
};
}

@ -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: () => {

@ -94,6 +94,7 @@ export class SoloPanelPage extends Component<Props, State> {
}
return (
<DashboardPanel
stateKey={panel.key}
width={width}
height={height}
dashboard={dashboard}

@ -179,7 +179,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
isViewing={panel.isViewing}
>
{(width: number, height: number) => {
return this.renderPanel(panel, width, height, panel.key);
return this.renderPanel(panel, width, height);
}}
</GrafanaGridItem>
);
@ -188,18 +188,19 @@ export class DashboardGrid extends PureComponent<Props, State> {
return panelElements;
}
renderPanel(panel: PanelModel, width: any, height: any, itemKey: string) {
renderPanel(panel: PanelModel, width: any, height: any) {
if (panel.type === 'row') {
return <DashboardRow key={itemKey} panel={panel} dashboard={this.props.dashboard} />;
return <DashboardRow key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
}
if (panel.type === 'add-panel') {
return <AddPanelWidget key={itemKey} panel={panel} dashboard={this.props.dashboard} />;
return <AddPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
}
return (
<DashboardPanel
key={itemKey}
key={panel.key}
stateKey={panel.key}
panel={panel}
dashboard={this.props.dashboard}
isEditing={panel.isEditing}

@ -1,27 +1,23 @@
// Libraries
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
// Components
import { PanelChrome } from './PanelChrome';
import { PanelChromeAngular } from './PanelChromeAngular';
// Actions
import { initDashboardPanel } from '../state/actions';
// Types
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { PanelPlugin } from '@grafana/data';
import { initPanelState } from '../../panel/state/actions';
import { cleanUpPanelState } from '../../panel/state/reducers';
export interface OwnProps {
panel: PanelModel;
stateKey: string;
dashboard: DashboardModel;
isEditing: boolean;
isViewing: boolean;
isInView: boolean;
width: number;
height: number;
skipStateCleanUp?: boolean;
}
export interface State {
@ -29,7 +25,7 @@ export interface State {
}
const mapStateToProps = (state: StoreState, props: OwnProps) => {
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<Props, State> {
}
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() {

@ -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<Props, State> {
export class PanelChrome extends PureComponent<Props, State> {
private readonly timeSrv: TimeSrv = getTimeSrv();
private subs = new Subscription();
private eventFilter: EventFilterOptions = { onlyLocal: true };
@ -103,7 +103,7 @@ export class PanelChrome extends Component<Props, State> {
});
// 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<Props, State> {
}
}
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

@ -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<Props, State> {
}
componentWillUnmount() {
this.cleanUpAngularPanel();
this.subs.unsubscribe();
}
@ -106,7 +106,6 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
const { plugin, height, width, panel } = this.props;
if (prevProps.plugin !== plugin) {
this.cleanUpAngularPanel();
this.loadAngularPanel();
}
@ -154,21 +153,11 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
};
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<Props, State> {
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
angularComponent: state.dashboard.panels[props.panel.id].angularComponent,
angularComponent: getPanelStateForModel(state, props.panel)?.angularComponent,
};
};

@ -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<Props> = ({ panel, dashboard, children }) => {
const [items, setItems] = useState<PanelMenuItem[]>([]);
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]);

@ -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();

@ -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);
});

@ -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<string, PanelOptionsCache> = {};
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.

@ -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<void> {
};
}
export function initDashboardPanel(panel: PanelModel): ThunkResult<void> {
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<void> {
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<void> => (dispatch, getStore) => {
const store = getStore();
const dashboard = store.dashboard.getModel();

@ -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', () => {

@ -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<DashboardInitError>) => {
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<PanelModelAndPluginReadyPayload>) => {
updatePanelState(state, action.payload.panelId, { plugin: action.payload.plugin });
},
cleanUpEditPanel: (state) => {
delete state.panels[EDIT_PANEL_ID];
},
setPanelInstanceState: (state, action: PayloadAction<SetPanelInstanceStatePayload>) => {
updatePanelState(state, action.payload.panelId, { instanceState: action.payload.value });
},
setPanelAngularComponent: (state, action: PayloadAction<SetPanelAngularComponentPayload>) => {
updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent });
},
addPanel: (state, action: PayloadAction<PanelModel>) => {
state.panels[action.payload.id] = { pluginId: action.payload.type };
//state.panels[action.payload.id] = { pluginId: action.payload.type };
},
},
});
export function updatePanelState(state: Draft<DashboardState>, panelId: number, ps: Partial<PanelState>) {
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;

@ -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];

@ -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;

@ -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<Props> = ({ 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),
});
setChangeToPanel(undefined);
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 = () => {

@ -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<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
const {
pluginId,

@ -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');
});
});
});

@ -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<void> {
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<void> {
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<void> {
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);
};
}

@ -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<string, PanelState>;
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<PanelModelAndPluginReadyPayload>) => {
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<SetPanelInstanceStatePayload>) => {
state[action.payload.key].instanceState = action.payload.value;
},
setPanelAngularComponent: (state, action: PayloadAction<SetPanelAngularComponentPayload>) => {
const panelState = state[action.payload.key];
cleanUpAngularComponent(panelState);
panelState.angularComponent = action.payload.angularComponent;
},
},
});
function cleanUpAngularComponent(panelState?: Draft<PanelState>) {
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,
};

@ -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];
}

@ -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';

@ -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,

@ -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<grafanaData.PanelPlugin>;
}
const panelCache: PanelCache = {};
export function importPanelPlugin(id: string): Promise<grafanaData.PanelPlugin> {
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<grafanaData.PanelPlugin> {
return getPanelPlugin(meta);
}
function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise<grafanaData.PanelPlugin> {
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);
});
}

@ -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';

@ -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<grafanaDa
return plugin;
});
}
import { getPanelPluginLoadError } from '../dashboard/dashgrid/PanelPluginError';
import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings';
interface PanelCache {
[key: string]: Promise<grafanaData.PanelPlugin>;
}
const panelCache: PanelCache = {};
export function importPanelPlugin(id: string): Promise<grafanaData.PanelPlugin> {
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<grafanaData.PanelPlugin> {
return getPanelPlugin(meta);
}
function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise<grafanaData.PanelPlugin> {
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);
});
}

@ -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,

@ -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
*/

@ -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';

@ -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 };

@ -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 };
}

Loading…
Cancel
Save