From d0f6a3a4ca5cb3e1f942e802be35b57c4b9c5021 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 4 Jan 2023 14:29:22 +0100 Subject: [PATCH] Scene: Dashboard loader supports transformations & unit tests (#60855) * Scene: use transformations for panel queries * refactor: Clean up DashboardLoader class * test: Add unit tests for creators * test: Ensure the row herarchy is properly replicated * test: Ensure the URLSync is initialized * refactor: Use fixture to create panels --- .../state/__fixtures__/dashboardFixtures.ts | 3 +- .../scenes/dashboard/DashboardsLoader.test.ts | 311 +++++++++++++++++- .../scenes/dashboard/DashboardsLoader.ts | 235 +++++++------ 3 files changed, 422 insertions(+), 127 deletions(-) diff --git a/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts b/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts index ae33fe267a0..147f51aa03d 100644 --- a/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts +++ b/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts @@ -5,6 +5,7 @@ import { defaultVariableModel, GraphPanel, Panel, + RowPanel, VariableModel, } from '@grafana/schema'; import { GetVariables } from 'app/features/variables/state/selectors'; @@ -29,7 +30,7 @@ export function createDashboardModelFixture( return new DashboardModel(dashboardJson, meta, getVariablesFromState); } -export function createPanelJSONFixture(panelInput: Partial = {}): Panel { +export function createPanelJSONFixture(panelInput: Partial = {}): Panel { return { fieldConfig: { defaults: {}, diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts index 432741a2ab2..f9ea8d38902 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts @@ -1,13 +1,308 @@ -import { VariableType } from '@grafana/schema'; +import { defaultDashboard, LoadingState, Panel, RowPanel, VariableType } from '@grafana/schema'; +import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; +import { SceneGridLayout, SceneGridRow, VizPanel } from '../components'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; import { CustomVariable } from '../variables/variants/CustomVariable'; import { DataSourceVariable } from '../variables/variants/DataSourceVariable'; import { QueryVariable } from '../variables/variants/query/QueryVariable'; -import { createVariableFromLegacyModel } from './DashboardsLoader'; +import { DashboardScene } from './DashboardScene'; +import { + createDashboardSceneFromDashboardModel, + createSceneVariableFromVariableModel, + DashboardLoader, + createVizPanelFromPanelModel, +} from './DashboardsLoader'; describe('DashboardLoader', () => { - describe('variables migration', () => { + describe('when fetching/loading a dashboard', () => { + beforeEach(() => { + new DashboardLoader({}); + }); + + it('should load the dashboard from the cache if it exists', () => { + const loader = new DashboardLoader({}); + const dashboard = new DashboardScene({ + title: 'cached', + uid: 'fake-uid', + body: new SceneGridLayout({ children: [] }), + }); + // @ts-expect-error + loader.cache['fake-uid'] = dashboard; + loader.load('fake-uid'); + expect(loader.state.dashboard).toBe(dashboard); + expect(loader.state.isLoading).toBe(undefined); + }); + + it('should call dashboard loader server if the dashboard is not cached', async () => { + const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); + setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + } as unknown as DashboardLoaderSrv); + + const loader = new DashboardLoader({}); + await loader.load('fake-dash'); + + expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); + }); + + it("should error when the dashboard doesn't exist", async () => { + const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: undefined, meta: undefined }); + setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + } as unknown as DashboardLoaderSrv); + + const loader = new DashboardLoader({}); + await loader.load('fake-dash'); + + expect(loader.state.dashboard).toBeUndefined(); + expect(loader.state.isLoading).toBe(false); + // @ts-expect-error - private + expect(loader.cache['fake-dash']).toBeUndefined(); + expect(loader.state.loadError).toBe('Error: Dashboard not found'); + }); + + it('should initialize the dashboard scene with the loaded dashboard', async () => { + const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); + setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + } as unknown as DashboardLoaderSrv); + + const loader = new DashboardLoader({}); + await loader.load('fake-dash'); + + expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); + expect(loader.state.loadError).toBe(undefined); + expect(loader.state.isLoading).toBe(false); + // It updates the cache + // @ts-expect-error - private + expect(loader.cache['fake-dash']).toBeDefined(); + }); + + it('should use DashboardScene creator to initialize the scene', async () => { + const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); + setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + } as unknown as DashboardLoaderSrv); + + const loader = new DashboardLoader({}); + await loader.load('fake-dash'); + expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); + // @ts-expect-error - private + expect(loader.state.dashboard?.urlSyncManager).toBeDefined(); + expect(loader.state.isLoading).toBe(false); + }); + }); + + describe('when creating dashboard scene', () => { + it('should initialize the DashboardScene with the model state', () => { + const dash = { + ...defaultDashboard, + title: 'test', + uid: 'test-uid', + time: { from: 'now-10h', to: 'now' }, + templating: { + list: [ + { + hide: 2, + name: 'constant', + skipUrlSync: false, + type: 'constant' as VariableType, + rootStateKey: 'N4XLmH5Vz', + query: 'test', + id: 'constant', + global: false, + index: 3, + state: LoadingState.Done, + error: null, + description: '', + datasource: null, + }, + ], + }, + }; + const oldModel = new DashboardModel(dash); + + const scene = createDashboardSceneFromDashboardModel(oldModel); + + expect(scene.state.title).toBe('test'); + expect(scene.state.uid).toBe('test-uid'); + expect(scene.state?.$timeRange?.state.value.raw).toEqual(dash.time); + expect(scene.state?.$variables?.state.variables).toHaveLength(1); + expect(scene.state.subMenu).toBeDefined(); + }); + }); + + describe('when organizing panels as scene children', () => { + it('should create panels within collapsed rows', () => { + const panel = createPanelJSONFixture({ + title: 'test', + gridPos: { x: 1, y: 0, w: 12, h: 8 }, + }) as Panel; + + const row = createPanelJSONFixture({ + title: 'test', + type: 'row', + gridPos: { x: 0, y: 0, w: 12, h: 1 }, + collapsed: true, + panels: [panel], + }) as unknown as RowPanel; + + const dashboard = { + ...defaultDashboard, + panels: [row], + }; + + const oldModel = new DashboardModel(dashboard); + + const scene = createDashboardSceneFromDashboardModel(oldModel); + + expect(scene.state.body.state.children).toHaveLength(1); + const rowScene = scene.state.body.state.children[0] as SceneGridRow; + expect(rowScene).toBeInstanceOf(SceneGridRow); + expect(rowScene.state.title).toEqual(row.title); + expect(rowScene.state.placement?.y).toEqual(row.gridPos!.y); + expect(rowScene.state.isCollapsed).toEqual(row.collapsed); + expect(rowScene.state.children).toHaveLength(1); + expect(rowScene.state.children[0]).toBeInstanceOf(VizPanel); + }); + + it('should create panels within expanded row', () => { + const panelOutOfRow = createPanelJSONFixture({ + title: 'Out of a row', + gridPos: { + h: 8, + w: 12, + x: 0, + y: 0, + }, + }); + const rowWithPanel = createPanelJSONFixture({ + title: 'Row with panel', + type: 'row', + collapsed: false, + gridPos: { + h: 1, + w: 24, + x: 0, + y: 8, + }, + // This panels array is not used if the row is not collapsed + panels: [], + }); + const panelInRow = createPanelJSONFixture({ + gridPos: { + h: 8, + w: 12, + x: 0, + y: 9, + }, + title: 'In row 1', + }); + const emptyRow = createPanelJSONFixture({ + collapsed: false, + gridPos: { + h: 1, + w: 24, + x: 0, + y: 17, + }, + // This panels array is not used if the row is not collapsed + panels: [], + title: 'Empty row', + type: 'row', + }); + const dashboard = { + ...defaultDashboard, + panels: [panelOutOfRow, rowWithPanel, panelInRow, emptyRow], + }; + + const oldModel = new DashboardModel(dashboard); + + const scene = createDashboardSceneFromDashboardModel(oldModel); + + expect(scene.state.body.state.children).toHaveLength(3); + expect(scene.state.body).toBeInstanceOf(SceneGridLayout); + // Panel out of row + expect(scene.state.body.state.children[0]).toBeInstanceOf(VizPanel); + const panelOutOfRowVizPanel = scene.state.body.state.children[0] as VizPanel; + expect(panelOutOfRowVizPanel.state.title).toBe(panelOutOfRow.title); + // Row with panel + expect(scene.state.body.state.children[1]).toBeInstanceOf(SceneGridRow); + const rowWithPanelsScene = scene.state.body.state.children[1] as SceneGridRow; + expect(rowWithPanelsScene.state.title).toBe(rowWithPanel.title); + expect(rowWithPanelsScene.state.children).toHaveLength(1); + // Panel within row + expect(rowWithPanelsScene.state.children[0]).toBeInstanceOf(VizPanel); + const panelInRowVizPanel = rowWithPanelsScene.state.children[0] as VizPanel; + expect(panelInRowVizPanel.state.title).toBe(panelInRow.title); + // Empty row + expect(scene.state.body.state.children[2]).toBeInstanceOf(SceneGridRow); + const emptyRowScene = scene.state.body.state.children[2] as SceneGridRow; + expect(emptyRowScene.state.title).toBe(emptyRow.title); + expect(emptyRowScene.state.children).toHaveLength(0); + }); + }); + + describe('when creating viz panel objects', () => { + it('should initalize the VizPanel scene object state', () => { + const panel = { + title: 'test', + type: 'test-plugin', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + options: { + fieldOptions: { + defaults: { + unit: 'none', + decimals: 2, + }, + overrides: [], + }, + }, + fieldConfig: { + defaults: { + unit: 'none', + }, + }, + pluginVersion: '1.0.0', + transformations: [ + { + id: 'reduce', + options: { + reducers: [ + { + id: 'mean', + }, + ], + }, + }, + ], + targets: [ + { + refId: 'A', + queryType: 'randomWalk', + }, + ], + }; + const vizPanelSceneObject = createVizPanelFromPanelModel(new PanelModel(panel)); + + expect(vizPanelSceneObject.state.title).toBe('test'); + expect(vizPanelSceneObject.state.pluginId).toBe('test-plugin'); + expect(vizPanelSceneObject.state.placement).toEqual({ x: 0, y: 0, width: 12, height: 8 }); + expect(vizPanelSceneObject.state.options).toEqual(panel.options); + expect(vizPanelSceneObject.state.fieldConfig).toEqual(panel.fieldConfig); + expect(vizPanelSceneObject.state.pluginVersion).toBe('1.0.0'); + expect((vizPanelSceneObject.state.$data as SceneQueryRunner)?.state.queries).toEqual(panel.targets); + expect((vizPanelSceneObject.state.$data as SceneQueryRunner)?.state.transformations).toEqual( + panel.transformations + ); + }); + }); + + describe('when creating variables objects', () => { it('should migrate custom variable', () => { const variable = { current: { @@ -54,7 +349,7 @@ describe('DashboardLoader', () => { allValue: null, }; - const migrated = createVariableFromLegacyModel(variable); + const migrated = createSceneVariableFromVariableModel(variable); const { key, ...rest } = migrated.state; expect(migrated).toBeInstanceOf(CustomVariable); @@ -133,7 +428,7 @@ describe('DashboardLoader', () => { description: null, }; - const migrated = createVariableFromLegacyModel(variable); + const migrated = createSceneVariableFromVariableModel(variable); const { key, ...rest } = migrated.state; expect(migrated).toBeInstanceOf(QueryVariable); @@ -205,7 +500,7 @@ describe('DashboardLoader', () => { allValue: 'Custom all', }; - const migrated = createVariableFromLegacyModel(variable); + const migrated = createSceneVariableFromVariableModel(variable); const { key, ...rest } = migrated.state; expect(migrated).toBeInstanceOf(DataSourceVariable); @@ -257,7 +552,7 @@ describe('DashboardLoader', () => { description: null, }; - const migrated = createVariableFromLegacyModel(variable); + const migrated = createSceneVariableFromVariableModel(variable); const { key, ...rest } = migrated.state; expect(rest).toEqual({ @@ -277,7 +572,7 @@ describe('DashboardLoader', () => { type: type as VariableType, }; - expect(() => createVariableFromLegacyModel(variable)).toThrow(); + expect(() => createSceneVariableFromVariableModel(variable)).toThrow(); }); }); }); diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 6da55aaeb65..4e757f02daf 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -48,7 +48,7 @@ export class DashboardLoader extends StateManagerBase { if (rsp.dashboard) { this.initDashboard(rsp); } else { - throw new Error('No dashboard returned'); + throw new Error('Dashboard not found'); } } catch (err) { this.setState({ isLoading: false, loadError: String(err) }); @@ -59,30 +59,7 @@ export class DashboardLoader extends StateManagerBase { // Just to have migrations run const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); - let subMenu: SceneSubMenu | undefined = undefined; - let variables: SceneVariableSet | undefined = undefined; - - if (oldModel.templating.list.length) { - const variableObjects = this.migrateVariables(oldModel); - subMenu = new SceneSubMenu({ - children: [new VariableValueSelectors({})], - }); - variables = new SceneVariableSet({ - variables: variableObjects, - }); - } - - const dashboard = new DashboardScene({ - title: oldModel.title, - uid: oldModel.uid, - body: new SceneGridLayout({ - children: this.buildSceneObjectsFromDashboard(oldModel), - }), - $timeRange: new SceneTimeRange(), - actions: [new SceneTimePicker({})], - $variables: variables, - subMenu, - }); + const dashboard = createDashboardSceneFromDashboardModel(oldModel); // We initialize URL sync here as it better to do that before mounting and doing any rendering. // But would be nice to have a conditional around this so you can pre-load dashboards without url sync. @@ -91,100 +68,121 @@ export class DashboardLoader extends StateManagerBase { this.cache[rsp.dashboard.uid] = dashboard; this.setState({ dashboard, isLoading: false }); } - - private buildSceneObjectsFromDashboard(dashboard: DashboardModel) { - // collects all panels and rows - const panels: SceneObject[] = []; - - // indicates expanded row that's currently processed - let currentRow: PanelModel | null = null; - // collects panels in the currently processed, expanded row - let currentRowPanels: SceneObject[] = []; - - for (const panel of dashboard.panels) { - if (panel.type === 'row') { - if (!currentRow) { - if (Boolean(panel.collapsed)) { - // collapsed rows contain their panels within the row model - panels.push( - new SceneGridRow({ - title: panel.title, - isCollapsed: true, - placement: { - y: panel.gridPos.y, - }, - children: panel.panels ? panel.panels.map(createVizPanelFromPanelModel) : [], - }) - ); - } else { - // indicate new row to be processed - currentRow = panel; - } - } else { - // when a row has been processed, and we hit a next one for processing - if (currentRow.id !== panel.id) { - // commit previous row panels - panels.push( - new SceneGridRow({ - title: currentRow!.title, - placement: { - y: currentRow.gridPos.y, - }, - children: currentRowPanels, - }) - ); - - currentRow = panel; - currentRowPanels = []; - } - } - } else { - const panelObject = createVizPanelFromPanelModel(panel); - - // when processing an expanded row, collect its panels - if (currentRow) { - currentRowPanels.push(panelObject); - } else { - panels.push(panelObject); - } - } - } - - // commit a row if it's the last one - if (currentRow) { - panels.push( - new SceneGridRow({ - title: currentRow!.title, - placement: { - y: currentRow.gridPos.y, - }, - children: currentRowPanels, - }) - ); - } - - return panels; - } - - private migrateVariables(dashboard: DashboardModel) { - return ( - dashboard.templating.list - .map((v) => { - try { - return createVariableFromLegacyModel(v); - } catch (err) { - console.error(err); - return null; - } - }) - // TODO: Remove filter - // Added temporarily to allow skipping non-compatible variables - .filter((v): v is SceneVariable => Boolean(v)) - ); - } } -export function createVariableFromLegacyModel(variable: VariableModel): SceneVariable { +export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneObject[] { + // collects all panels and rows + const panels: SceneObject[] = []; + + // indicates expanded row that's currently processed + let currentRow: PanelModel | null = null; + // collects panels in the currently processed, expanded row + let currentRowPanels: SceneObject[] = []; + + for (const panel of oldPanels) { + if (panel.type === 'row') { + if (!currentRow) { + if (Boolean(panel.collapsed)) { + // collapsed rows contain their panels within the row model + panels.push( + new SceneGridRow({ + title: panel.title, + isCollapsed: true, + placement: { + y: panel.gridPos.y, + }, + children: panel.panels ? panel.panels.map(createVizPanelFromPanelModel) : [], + }) + ); + } else { + // indicate new row to be processed + currentRow = panel; + } + } else { + // when a row has been processed, and we hit a next one for processing + if (currentRow.id !== panel.id) { + // commit previous row panels + panels.push( + new SceneGridRow({ + title: currentRow!.title, + placement: { + y: currentRow.gridPos.y, + }, + children: currentRowPanels, + }) + ); + + currentRow = panel; + currentRowPanels = []; + } + } + } else { + const panelObject = createVizPanelFromPanelModel(panel); + + // when processing an expanded row, collect its panels + if (currentRow) { + currentRowPanels.push(panelObject); + } else { + panels.push(panelObject); + } + } + } + + // commit a row if it's the last one + if (currentRow) { + panels.push( + new SceneGridRow({ + title: currentRow!.title, + placement: { + y: currentRow.gridPos.y, + }, + children: currentRowPanels, + }) + ); + } + + return panels; +} + +export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) { + let subMenu: SceneSubMenu | undefined = undefined; + let variables: SceneVariableSet | undefined = undefined; + + if (oldModel.templating.list.length) { + const variableObjects = oldModel.templating.list + .map((v) => { + try { + return createSceneVariableFromVariableModel(v); + } catch (err) { + console.error(err); + return null; + } + }) + // TODO: Remove filter + // Added temporarily to allow skipping non-compatible variables + .filter((v): v is SceneVariable => Boolean(v)); + subMenu = new SceneSubMenu({ + children: [new VariableValueSelectors({})], + }); + variables = new SceneVariableSet({ + variables: variableObjects, + }); + } + + return new DashboardScene({ + title: oldModel.title, + uid: oldModel.uid, + body: new SceneGridLayout({ + children: createSceneObjectsForPanels(oldModel.panels), + }), + $timeRange: new SceneTimeRange(oldModel.time), + actions: [new SceneTimePicker({})], + $variables: variables, + subMenu, + }); +} + +export function createSceneVariableFromVariableModel(variable: VariableModel): SceneVariable { const commonProperties = { name: variable.name, label: variable.label, @@ -249,7 +247,7 @@ export function createVariableFromLegacyModel(variable: VariableModel): SceneVar } } -function createVizPanelFromPanelModel(panel: PanelModel) { +export function createVizPanelFromPanelModel(panel: PanelModel) { return new VizPanel({ title: panel.title, pluginId: panel.type, @@ -263,6 +261,7 @@ function createVizPanelFromPanelModel(panel: PanelModel) { fieldConfig: panel.fieldConfig, pluginVersion: panel.pluginVersion, $data: new SceneQueryRunner({ + transformations: panel.transformations, queries: panel.targets, }), });