From 412e545503d3d4a17cdb7298ad6f537d94f6475b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 29 Aug 2023 14:17:55 +0200 Subject: [PATCH] DashboardScene: Support for discard, start at transform back to save model and save drawer (#73873) * SceneDashboard: Discard changes now works * To save model works and start at save drawer * Update * Added missing file * Refactorings to keep responsibility more logical * Refactorings * Removed file * Fixed state issue * Update * Update --- .betterer.results | 6 + package.json | 2 +- .../inspect/PanelInspectDrawer.tsx | 4 +- .../pages/DashboardScenePage.tsx | 16 +- .../DashboardScenePageStateManager.test.ts | 78 +++++ .../pages/DashboardScenePageStateManager.ts | 58 ++++ .../scene/DashboardScene.test.tsx | 32 +- .../dashboard-scene/scene/DashboardScene.tsx | 150 ++++----- .../scene/DashboardSceneUrlSync.ts | 61 ++++ .../scene/NavToolbarActions.tsx | 4 +- .../serialization/SaveDashboardDrawer.tsx | 41 +++ .../transformSceneToSaveModel.test.ts.snap | 98 ++++++ .../testfiles/dashboard_to_load1.json | 294 ++++++++++++++++++ ...t.ts => transformSaveModelToScene.test.ts} | 81 +---- ...Loader.ts => transformSaveModelToScene.ts} | 59 +--- .../transformSceneToSaveModel.test.ts | 14 + .../transformSceneToSaveModel.ts | 59 ++++ .../features/dashboard-scene/utils/utils.ts | 4 + .../HelpWizard/SupportSnapshotService.ts | 2 +- yarn.lock | 10 +- 20 files changed, 850 insertions(+), 223 deletions(-) create mode 100644 public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts create mode 100644 public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts create mode 100644 public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts create mode 100644 public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx create mode 100644 public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap create mode 100644 public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json rename public/app/features/dashboard-scene/serialization/{DashboardsLoader.test.ts => transformSaveModelToScene.test.ts} (85%) rename public/app/features/dashboard-scene/serialization/{DashboardsLoader.ts => transformSaveModelToScene.ts} (84%) create mode 100644 public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts create mode 100644 public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts diff --git a/.betterer.results b/.betterer.results index 033dd17328e..a666a654948 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1916,6 +1916,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/dashboard-scene/utils/test-utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/package.json b/package.json index 558fca463b2..6860e761b02 100644 --- a/package.json +++ b/package.json @@ -245,7 +245,7 @@ "@grafana/lezer-traceql": "0.0.4", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^0.25.0", + "@grafana/scenes": "^0.26.0", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", "@kusto/monaco-kusto": "^7.4.0", diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 83e0418a481..c447e919151 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -24,7 +24,7 @@ interface PanelInspectDrawerState extends SceneObjectState { } export class PanelInspectDrawer extends SceneObjectBase { - static Component = ScenePanelInspectorRenderer; + static Component = PanelInspectRenderer; // Not stored in state as this is just a reference and it never changes private _panel: VizPanel; @@ -61,7 +61,7 @@ export class PanelInspectDrawer extends SceneObjectBase }; } -function ScenePanelInspectorRenderer({ model }: SceneComponentProps) { +function PanelInspectRenderer({ model }: SceneComponentProps) { const { tabs } = model.useState(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 711959833f2..031c49fd529 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -6,20 +6,20 @@ import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { getDashboardLoader } from '../serialization/DashboardsLoader'; +import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} -export const DashboardScenePage = ({ match }: Props) => { - const loader = getDashboardLoader(); - const { dashboard, isLoading } = loader.useState(); +export function DashboardScenePage({ match }: Props) { + const stateManager = getDashboardScenePageStateManager(); + const { dashboard, isLoading } = stateManager.useState(); useEffect(() => { - loader.loadAndInit(match.params.uid); + stateManager.loadAndInit(match.params.uid); return () => { - loader.clearState(); + stateManager.clearState(); }; - }, [loader, match.params.uid]); + }, [stateManager, match.params.uid]); if (!dashboard) { return ( @@ -31,6 +31,6 @@ export const DashboardScenePage = ({ match }: Props) => { } return ; -}; +} export default DashboardScenePage; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts new file mode 100644 index 00000000000..47933b27bcc --- /dev/null +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -0,0 +1,78 @@ +import { locationService } from '@grafana/runtime'; +import { getUrlSyncManager } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { setupLoadDashboardMock } from '../utils/test-utils'; + +import { DashboardScenePageStateManager } from './DashboardScenePageStateManager'; + +describe('DashboardScenePageStateManager', () => { + describe('when fetching/loading a dashboard', () => { + it('should call loader from server if the dashboard is not cached', async () => { + const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadAndInit('fake-dash'); + + expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); + + // should use cache second time + await loader.loadAndInit('fake-dash'); + expect(loadDashboardMock.mock.calls.length).toBe(1); + }); + + it("should error when the dashboard doesn't exist", async () => { + setupLoadDashboardMock({ dashboard: undefined, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadAndInit('fake-dash'); + + expect(loader.state.dashboard).toBeUndefined(); + expect(loader.state.isLoading).toBe(false); + expect(loader.state.loadError).toBe('Error: Dashboard not found'); + }); + + it('should initialize the dashboard scene with the loaded dashboard', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadAndInit('fake-dash'); + + expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); + expect(loader.state.loadError).toBe(undefined); + expect(loader.state.isLoading).toBe(false); + }); + + it('should use DashboardScene creator to initialize the scene', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadAndInit('fake-dash'); + + expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); + expect(loader.state.isLoading).toBe(false); + }); + + it('should initialize url sync', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + locationService.partial({ from: 'now-5m', to: 'now' }); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadAndInit('fake-dash'); + const dash = loader.state.dashboard; + + expect(dash!.state.$timeRange?.state.from).toEqual('now-5m'); + + getUrlSyncManager().cleanUp(dash!); + + // try loading again (and hitting cache) + locationService.partial({ from: 'now-10m', to: 'now' }); + + await loader.loadAndInit('fake-dash'); + const dash2 = loader.state.dashboard; + + expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts new file mode 100644 index 00000000000..c4ebb0866a8 --- /dev/null +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -0,0 +1,58 @@ +import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; + +export interface DashboardScenePageState { + dashboard?: DashboardScene; + isLoading?: boolean; + loadError?: string; +} + +export class DashboardScenePageStateManager extends StateManagerBase { + private cache: Record = {}; + + async loadAndInit(uid: string) { + try { + const scene = await this.loadScene(uid); + scene.startUrlSync(); + + this.cache[uid] = scene; + this.setState({ dashboard: scene, isLoading: false }); + } catch (err) { + this.setState({ isLoading: false, loadError: String(err) }); + } + } + + private async loadScene(uid: string): Promise { + const fromCache = this.cache[uid]; + if (fromCache) { + return fromCache; + } + + this.setState({ isLoading: true }); + + const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + + if (rsp.dashboard) { + return transformSaveModelToScene(rsp); + } + + throw new Error('Dashboard not found'); + } + + public clearState() { + this.setState({ dashboard: undefined, loadError: undefined, isLoading: false }); + } +} + +let stateManager: DashboardScenePageStateManager | null = null; + +export function getDashboardScenePageStateManager(): DashboardScenePageStateManager { + if (!stateManager) { + stateManager = new DashboardScenePageStateManager({}); + } + + return stateManager; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index bb27c46ba1d..07b488486ce 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -1,4 +1,4 @@ -import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { sceneGraph, SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; import { DashboardScene } from './DashboardScene'; @@ -22,6 +22,34 @@ describe('DashboardScene', () => { expect(scene.state.viewPanelKey).toBe('panel-2'); }); }); + + describe('Editing and discarding', () => { + describe('Given scene in edit mode', () => { + let scene: DashboardScene; + + beforeEach(() => { + scene = buildTestScene(); + scene.onEnterEditMode(); + }); + + it('Should set isEditing to true', () => { + expect(scene.state.isEditing).toBe(true); + }); + + it('A change to griditem pos should set isDirty true', () => { + const gridItem = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem; + gridItem.setState({ x: 10, y: 0, width: 10, height: 10 }); + + expect(scene.state.isDirty).toBe(true); + + // verify can discard change + scene.onDiscard(); + + const gridItem2 = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem; + expect(gridItem2.state.x).toBe(0); + }); + }); + }); }); function buildTestScene() { @@ -30,6 +58,8 @@ function buildTestScene() { body: new SceneGridLayout({ children: [ new SceneGridItem({ + key: 'griditem-1', + x: 0, body: new VizPanel({ title: 'Panel A', key: 'panel-1', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 825093c1999..0fd7f0276da 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,6 +1,7 @@ import * as H from 'history'; +import { Unsubscribable } from 'rxjs'; -import { AppEvents, locationUtil, NavModelItem } from '@grafana/data'; +import { locationUtil, NavModelItem, UrlQueryMap } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getUrlSyncManager, @@ -10,16 +11,16 @@ import { SceneObjectBase, SceneObjectState, SceneObjectStateChangedEvent, - SceneObjectUrlSyncHandler, - SceneObjectUrlValues, + sceneUtils, } from '@grafana/scenes'; -import appEvents from 'app/core/app_events'; -import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer'; +import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer'; import { findVizPanel } from '../utils/findVizPanel'; import { forceRenderChildren } from '../utils/utils'; +import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; + export interface DashboardSceneState extends SceneObjectState { title: string; uid?: string; @@ -39,45 +40,77 @@ export interface DashboardSceneState extends SceneObjectState { export class DashboardScene extends SceneObjectBase { static Component = DashboardSceneRenderer; + /** + * Handles url sync + */ protected _urlSync = new DashboardSceneUrlSync(this); + /** + * State before editing started + */ + private _initialState?: DashboardSceneState; + /** + * Url state before editing started + */ + private _initiallUrlState?: UrlQueryMap; + /** + * change tracking subscription + */ + private _changeTrackerSub?: Unsubscribable; - constructor(state: DashboardSceneState) { + public constructor(state: DashboardSceneState) { super(state); - this.addActivationHandler(() => { - return () => { - getUrlSyncManager().cleanUp(this); - }; - }); - - this.subscribeToEvent(SceneObjectStateChangedEvent, this.onChildStateChanged); + this.addActivationHandler(() => this.onActivate()); } - public onChildStateChanged = (event: SceneObjectStateChangedEvent) => { - // Temporary hacky way to detect changes - if (event.payload.changedObject instanceof SceneGridItem) { - this.setState({ isDirty: true }); + private onActivate() { + if (this.state.isEditing) { + this.startTrackingChanges(); } - }; - initUrlSync() { + // Deactivation logic + return () => { + this.stopTrackingChanges(); + this.stopUrlSync(); + }; + } + + public startUrlSync() { getUrlSyncManager().initSync(this); } - onEnterEditMode = () => { + public stopUrlSync() { + getUrlSyncManager().cleanUp(this); + } + + public onEnterEditMode = () => { + // Save this state + this._initialState = sceneUtils.cloneSceneObjectState(this.state); + this._initiallUrlState = locationService.getSearchObject(); + + // Switch to edit mode this.setState({ isEditing: true }); - // Make grid draggable + // Propagate change edit mode change to children if (this.state.body instanceof SceneGridLayout) { this.state.body.setState({ isDraggable: true, isResizable: true }); forceRenderChildren(this.state.body, true); } + + this.startTrackingChanges(); }; - onDiscard = () => { - // TODO open confirm modal if dirty - // TODO actually discard changes - this.setState({ isEditing: false }); + public onDiscard = () => { + // No need to listen to changes anymore + this.stopTrackingChanges(); + // Stop url sync before updating url + this.stopUrlSync(); + // Now we can update url + locationService.partial(this._initiallUrlState!, true); + // Update state and disable editing + this.setState({ ...this._initialState, isEditing: false }); + // and start url sync again + this.startUrlSync(); // Disable grid dragging if (this.state.body instanceof SceneGridLayout) { @@ -86,7 +119,11 @@ export class DashboardScene extends SceneObjectBase { } }; - getPageNav(location: H.Location) { + public onSave = () => { + this.setState({ drawer: new SaveDashboardDrawer(this) }); + }; + + public getPageNav(location: H.Location) { let pageNav: NavModelItem = { text: this.state.title, url: locationUtil.getUrlForPartial(location, { viewPanel: null, inspect: null }), @@ -105,60 +142,27 @@ export class DashboardScene extends SceneObjectBase { /** * Returns the body (layout) or the full view panel */ - getBodyToRender(viewPanelKey?: string): SceneObject { + public getBodyToRender(viewPanelKey?: string): SceneObject { const viewPanel = findVizPanel(this, viewPanelKey); return viewPanel ?? this.state.body; } -} -class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { - constructor(private _scene: DashboardScene) {} - - getKeys(): string[] { - return ['inspect', 'viewPanel']; + private startTrackingChanges() { + this._changeTrackerSub = this.subscribeToEvent( + SceneObjectStateChangedEvent, + (event: SceneObjectStateChangedEvent) => { + if (event.payload.changedObject instanceof SceneGridItem) { + this.setState({ isDirty: true }); + } + } + ); } - getUrlState(): SceneObjectUrlValues { - const state = this._scene.state; - return { inspect: state.inspectPanelKey, viewPanel: state.viewPanelKey }; + private stopTrackingChanges() { + this._changeTrackerSub?.unsubscribe(); } - updateFromUrl(values: SceneObjectUrlValues): void { - const { inspectPanelKey, viewPanelKey } = this._scene.state; - const update: Partial = {}; - - // Handle inspect object state - if (typeof values.inspect === 'string') { - const panel = findVizPanel(this._scene, values.inspect); - if (!panel) { - appEvents.emit(AppEvents.alertError, ['Panel not found']); - locationService.partial({ inspect: null }); - return; - } - - update.inspectPanelKey = values.inspect; - update.drawer = new PanelInspectDrawer(panel); - } else if (inspectPanelKey) { - update.inspectPanelKey = undefined; - update.drawer = undefined; - } - - // Handle view panel state - if (typeof values.viewPanel === 'string') { - const panel = findVizPanel(this._scene, values.viewPanel); - if (!panel) { - appEvents.emit(AppEvents.alertError, ['Panel not found']); - locationService.partial({ viewPanel: null }); - return; - } - - update.viewPanelKey = values.viewPanel; - } else if (viewPanelKey) { - update.viewPanelKey = undefined; - } - - if (Object.keys(update).length > 0) { - this._scene.setState(update); - } + public getInitialState(): DashboardSceneState | undefined { + return this._initialState; } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts new file mode 100644 index 00000000000..36835c80ad7 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -0,0 +1,61 @@ +import { AppEvents } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes'; +import appEvents from 'app/core/app_events'; + +import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; +import { findVizPanel } from '../utils/findVizPanel'; + +import { DashboardScene, DashboardSceneState } from './DashboardScene'; + +export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { + constructor(private _scene: DashboardScene) {} + + getKeys(): string[] { + return ['inspect', 'viewPanel']; + } + + getUrlState(): SceneObjectUrlValues { + const state = this._scene.state; + return { inspect: state.inspectPanelKey, viewPanel: state.viewPanelKey }; + } + + updateFromUrl(values: SceneObjectUrlValues): void { + const { inspectPanelKey, viewPanelKey } = this._scene.state; + const update: Partial = {}; + + // Handle inspect object state + if (typeof values.inspect === 'string') { + const panel = findVizPanel(this._scene, values.inspect); + if (!panel) { + appEvents.emit(AppEvents.alertError, ['Panel not found']); + locationService.partial({ inspect: null }); + return; + } + + update.inspectPanelKey = values.inspect; + update.drawer = new PanelInspectDrawer(panel); + } else if (inspectPanelKey) { + update.inspectPanelKey = undefined; + update.drawer = undefined; + } + + // Handle view panel state + if (typeof values.viewPanel === 'string') { + const panel = findVizPanel(this._scene, values.viewPanel); + if (!panel) { + appEvents.emit(AppEvents.alertError, ['Panel not found']); + locationService.partial({ viewPanel: null }); + return; + } + + update.viewPanelKey = values.viewPanel; + } else if (viewPanelKey) { + update.viewPanelKey = undefined; + } + + if (Object.keys(update).length > 0) { + this._scene.setState(update); + } + } +} diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 158c0424bd1..85c3e32a480 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -62,7 +62,7 @@ export const NavToolbarActions = React.memo(({ dashboard }) => { } else { // TODO check permissions toolbarActions.push( - ); @@ -72,7 +72,7 @@ export const NavToolbarActions = React.memo(({ dashboard }) => { ); toolbarActions.push( - ); diff --git a/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx new file mode 100644 index 00000000000..69a1de1ed0e --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { Drawer } from '@grafana/ui'; +import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff'; +import { jsonDiff } from 'app/features/dashboard/components/VersionHistory/utils'; + +import { DashboardScene } from '../scene/DashboardScene'; + +import { transformSceneToSaveModel } from './transformSceneToSaveModel'; + +interface SaveDashboardDrawerState extends SceneObjectState {} + +export class SaveDashboardDrawer extends SceneObjectBase { + constructor(public dashboard: DashboardScene) { + super({}); + } + + onClose = () => { + this.dashboard.setState({ drawer: undefined }); + }; + + static Component = ({ model }: SceneComponentProps) => { + const initialScene = new DashboardScene(model.dashboard.getInitialState()!); + const initialSaveModel = transformSceneToSaveModel(initialScene); + const changedSaveModel = transformSceneToSaveModel(model.dashboard); + + const diff = jsonDiff(initialSaveModel, changedSaveModel); + + // let diffCount = 0; + // for (const d of Object.values(diff)) { + // diffCount += d.length; + // } + + return ( + + + + ); + }; +} diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap new file mode 100644 index 00000000000..47fe78b5b92 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`transformSceneToSaveModel Given a scene Should transfrom back to peristed model 1`] = ` +{ + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false, + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear", + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none", + }, + "thresholdsStyle": { + "mode": "off", + }, + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + }, + { + "color": "red", + "value": 80, + }, + ], + }, + }, + "overrides": [], + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0, + }, + "id": 28, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + }, + "tooltip": { + "mode": "single", + "sort": "none", + }, + }, + "title": "Simple time series graph ", + "transformations": [], + "transparent": false, + "type": "timeseries", + }, + ], + "schemaVersion": 36, + "tags": [], + "time": { + "from": "now-5m", + "to": "now", + }, + "timezone": "browser", + "title": "Dashboard to load1", + "uid": "nP8rcffGkasd", +} +`; diff --git a/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json b/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json new file mode 100644 index 00000000000..ddb900f0020 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json @@ -0,0 +1,294 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1351, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 28, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "alias": "series", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 1 + } + ], + "title": "Simple time series graph ", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 5, + "panels": [], + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Row title", + "type": "row" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "alias": "series", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 1 + } + ], + "title": "panel inside row", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 10, + "w": 11, + "x": 12, + "y": 9 + }, + "id": 25, + "links": [], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "content", + "mode": "markdown" + }, + "pluginVersion": "10.2.0-pre", + "targets": [ + { + "alias": "__server_names", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 6 + } + ], + "title": "Transparent text panel", + "transparent": true, + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": ["gdev", "graph-ng", "demo"], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "America/New_York", + "title": "Dashboard to load1", + "uid": "nP8rcffGkasd", + "version": 2, + "weekStart": "" +} diff --git a/public/app/features/dashboard-scene/serialization/DashboardsLoader.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts similarity index 85% rename from public/app/features/dashboard-scene/serialization/DashboardsLoader.test.ts rename to public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index a5378a1f28c..ded61fd16c5 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardsLoader.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -1,10 +1,9 @@ import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { config, locationService } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { behaviors, CustomVariable, DataSourceVariable, - getUrlSyncManager, QueryVariable, SceneDataTransformer, SceneGridItem, @@ -19,91 +18,15 @@ import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures_ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; -import { DashboardScene } from '../scene/DashboardScene'; import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider'; -import { setupLoadDashboardMock } from '../utils/test-utils'; import { createDashboardSceneFromDashboardModel, createVizPanelFromPanelModel, createSceneVariableFromVariableModel, - DashboardLoader, -} from './DashboardsLoader'; +} from './transformSaveModelToScene'; describe('DashboardLoader', () => { - describe('when fetching/loading a dashboard', () => { - beforeEach(() => { - new DashboardLoader({}); - }); - - it('should call dashboard loader server if the dashboard is not cached', async () => { - const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); - - const loader = new DashboardLoader({}); - await loader.loadAndInit('fake-dash'); - - expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); - - // should use cache second time - await loader.loadAndInit('fake-dash'); - expect(loadDashboardMock.mock.calls.length).toBe(1); - }); - - it("should error when the dashboard doesn't exist", async () => { - setupLoadDashboardMock({ dashboard: undefined, meta: {} }); - - const loader = new DashboardLoader({}); - await loader.loadAndInit('fake-dash'); - - expect(loader.state.dashboard).toBeUndefined(); - expect(loader.state.isLoading).toBe(false); - expect(loader.state.loadError).toBe('Error: Dashboard not found'); - }); - - it('should initialize the dashboard scene with the loaded dashboard', async () => { - setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); - - const loader = new DashboardLoader({}); - await loader.loadAndInit('fake-dash'); - - expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); - expect(loader.state.loadError).toBe(undefined); - expect(loader.state.isLoading).toBe(false); - }); - - it('should use DashboardScene creator to initialize the scene', async () => { - setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); - - const loader = new DashboardLoader({}); - await loader.loadAndInit('fake-dash'); - - expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); - expect(loader.state.isLoading).toBe(false); - }); - - it('should initialize url sync', async () => { - setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); - - locationService.partial({ from: 'now-5m', to: 'now' }); - - const loader = new DashboardLoader({}); - await loader.loadAndInit('fake-dash'); - const dash = loader.state.dashboard; - - expect(dash!.state.$timeRange?.state.from).toEqual('now-5m'); - - getUrlSyncManager().cleanUp(dash!); - - // try loading again (and hitting cache) - locationService.partial({ from: 'now-10m', to: 'now' }); - - await loader.loadAndInit('fake-dash'); - const dash2 = loader.state.dashboard; - - expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); - }); - }); - describe('when creating dashboard scene', () => { it('should initialize the DashboardScene with the model state', () => { const dash = { diff --git a/public/app/features/dashboard-scene/serialization/DashboardsLoader.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts similarity index 84% rename from public/app/features/dashboard-scene/serialization/DashboardsLoader.ts rename to public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index a0d41055526..27d7767c5ff 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardsLoader.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -25,9 +25,8 @@ import { VizPanelMenu, behaviors, } from '@grafana/scenes'; -import { StateManagerBase } from 'app/core/services/StateManagerBase'; -import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardDTO } from 'app/types'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; @@ -41,46 +40,13 @@ export interface DashboardLoaderState { loadError?: string; } -export class DashboardLoader extends StateManagerBase { - private cache: Record = {}; +export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene { + // Just to have migrations run + const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { + autoMigrateOldPanels: true, + }); - async loadAndInit(uid: string) { - try { - const scene = await this.loadScene(uid); - scene.initUrlSync(); - - this.cache[uid] = scene; - this.setState({ dashboard: scene, isLoading: false }); - } catch (err) { - this.setState({ isLoading: false, loadError: String(err) }); - } - } - - private async loadScene(uid: string): Promise { - const fromCache = this.cache[uid]; - if (fromCache) { - return fromCache; - } - - this.setState({ isLoading: true }); - - const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); - - if (rsp.dashboard) { - // Just to have migrations run - const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { - autoMigrateOldPanels: true, - }); - - return createDashboardSceneFromDashboardModel(oldModel); - } - - throw new Error('Dashboard not found'); - } - - public clearState() { - this.setState({ dashboard: undefined, loadError: undefined, isLoading: false }); - } + return createDashboardSceneFromDashboardModel(oldModel); } export function createSceneObjectsForPanels(oldPanels: PanelModel[]): Array { @@ -280,6 +246,7 @@ export function createSceneVariableFromVariableModel(variable: VariableModel): S export function createVizPanelFromPanelModel(panel: PanelModel) { return new SceneGridItem({ + key: `grid-item-${panel.id}`, x: panel.gridPos.x, y: panel.gridPos.y, width: panel.gridPos.w, @@ -302,16 +269,6 @@ export function createVizPanelFromPanelModel(panel: PanelModel) { }); } -let loader: DashboardLoader | null = null; - -export function getDashboardLoader(): DashboardLoader { - if (!loader) { - loader = new DashboardLoader({}); - } - - return loader; -} - const isCustomVariable = (v: VariableModel): v is CustomVariableModel => v.type === 'custom'; const isQueryVariable = (v: VariableModel): v is QueryVariableModel => v.type === 'query'; const isDataSourceVariable = (v: VariableModel): v is DataSourceVariableModel => v.type === 'datasource'; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts new file mode 100644 index 00000000000..e31f29c6fcf --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -0,0 +1,14 @@ +import dashboard_to_load1 from './testfiles/dashboard_to_load1.json'; +import { transformSaveModelToScene } from './transformSaveModelToScene'; +import { transformSceneToSaveModel } from './transformSceneToSaveModel'; + +describe('transformSceneToSaveModel', () => { + describe('Given a scene', () => { + it('Should transfrom back to peristed model', () => { + const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); + const saveModel = transformSceneToSaveModel(scene); + + expect(saveModel).toMatchSnapshot(); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts new file mode 100644 index 00000000000..1a1fbfdf27f --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -0,0 +1,59 @@ +import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { Dashboard, defaultDashboard, FieldConfigSource, Panel } from '@grafana/schema'; +import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { getPanelIdForVizPanelKey } from '../utils/utils'; + +export function transformSceneToSaveModel(scene: DashboardScene): Dashboard { + const state = scene.state; + const timeRange = state.$timeRange!.state; + const body = state.body; + const panels: Panel[] = []; + + if (body instanceof SceneGridLayout) { + for (const child of body.state.children) { + if (child instanceof SceneGridItem) { + panels.push(gridItemToPanel(child)); + } + } + } + + const dashboard: Dashboard = { + ...defaultDashboard, + title: state.title, + uid: state.uid, + time: { + from: timeRange.from, + to: timeRange.to, + }, + panels, + }; + + return sortedDeepCloneWithoutNulls(dashboard); +} + +function gridItemToPanel(gridItem: SceneGridItem): Panel { + const vizPanel = gridItem.state.body; + if (!(vizPanel instanceof VizPanel)) { + throw new Error('SceneGridItem body expected to be VizPanel'); + } + + const panel: Panel = { + id: getPanelIdForVizPanelKey(vizPanel.state.key!), + type: vizPanel.state.pluginId, + title: vizPanel.state.title, + gridPos: { + x: gridItem.state.x ?? 0, + y: gridItem.state.y ?? 0, + w: gridItem.state.width ?? 0, + h: gridItem.state.height ?? 0, + }, + options: vizPanel.state.options, + fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] }, + transformations: [], + transparent: false, + }; + + return panel; +} diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 5939892c0fb..f63a3e9dabf 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -4,6 +4,10 @@ export function getVizPanelKeyForPanelId(panelId: number) { return `panel-${panelId}`; } +export function getPanelIdForVizPanelKey(key: string) { + return parseInt(key.replace('panel-', ''), 10); +} + /** * Useful from tests to simulate mounting a full scene. Children are activated before parents to simulate the real order * of React mount order and useEffect ordering. diff --git a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts index 4b8dd8e83c9..f771602d0a2 100644 --- a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts +++ b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts @@ -4,7 +4,7 @@ import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue import { config } from '@grafana/runtime'; import { SceneObject } from '@grafana/scenes'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; -import { createDashboardSceneFromDashboardModel } from 'app/features/dashboard-scene/serialization/DashboardsLoader'; +import { createDashboardSceneFromDashboardModel } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; import { getTimeSrv } from '../../services/TimeSrv'; import { DashboardModel, PanelModel } from '../../state'; diff --git a/yarn.lock b/yarn.lock index c5c14dd4f55..8e442bb9325 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3937,9 +3937,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:^0.25.0": - version: 0.25.0 - resolution: "@grafana/scenes@npm:0.25.0" +"@grafana/scenes@npm:^0.26.0": + version: 0.26.0 + resolution: "@grafana/scenes@npm:0.26.0" dependencies: "@grafana/e2e-selectors": 10.0.2 react-grid-layout: 1.3.4 @@ -3951,7 +3951,7 @@ __metadata: "@grafana/runtime": 10.0.3 "@grafana/schema": 10.0.3 "@grafana/ui": 10.0.3 - checksum: ab81d18bb65b0b5d612708fbd9351022141248b476009bdde01660cce1747bd0724fdf599842127f48feea49b31858dc64e45af2638d90fd3b09d59e350f79e4 + checksum: 041458e463cde07179c75444f245411715ed50f072a5c4d519e258d0e0d57864c55e9cce3fb245943e2bc4245ee48e243701f5f0290c33e0374d4e948820eb14 languageName: node linkType: hard @@ -19257,7 +19257,7 @@ __metadata: "@grafana/lezer-traceql": 0.0.4 "@grafana/monaco-logql": ^0.0.7 "@grafana/runtime": "workspace:*" - "@grafana/scenes": ^0.25.0 + "@grafana/scenes": ^0.26.0 "@grafana/schema": "workspace:*" "@grafana/toolkit": "workspace:*" "@grafana/tsconfig": ^1.3.0-rc1