mirror of https://github.com/grafana/grafana
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 thunkpull/40100/head
parent
3d9e2d8c82
commit
d62ca1283c
@ -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]; |
||||
} |
@ -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); |
||||
}); |
||||
} |
Loading…
Reference in new issue