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
This commit is contained in:
Torkel Ödegaard 2023-08-29 14:17:55 +02:00 committed by GitHub
parent 45a8ca3111
commit 412e545503
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 850 additions and 223 deletions

View File

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

View File

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

View File

@ -24,7 +24,7 @@ interface PanelInspectDrawerState extends SceneObjectState {
}
export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState> {
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<PanelInspectDrawerState>
};
}
function ScenePanelInspectorRenderer({ model }: SceneComponentProps<PanelInspectDrawer>) {
function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>) {
const { tabs } = model.useState();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);

View File

@ -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 <dashboard.Component model={dashboard} />;
};
}
export default DashboardScenePage;

View File

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

View File

@ -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<DashboardScenePageState> {
private cache: Record<string, DashboardScene> = {};
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<DashboardScene> {
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;
}

View File

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

View File

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

View File

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

View File

@ -62,7 +62,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
} else {
// TODO check permissions
toolbarActions.push(
<Button onClick={dashboard.onEnterEditMode} tooltip="Save as copy" fill="text" key="save-as">
<Button onClick={dashboard.onSave} tooltip="Save as copy" fill="text" key="save-as">
Save as
</Button>
);
@ -72,7 +72,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onEnterEditMode} tooltip="Save changes" key="save" disabled={!isDirty}>
<Button onClick={dashboard.onSave} tooltip="Save changes" key="save" disabled={!isDirty}>
Save
</Button>
);

View File

@ -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<SaveDashboardDrawerState> {
constructor(public dashboard: DashboardScene) {
super({});
}
onClose = () => {
this.dashboard.setState({ drawer: undefined });
};
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
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 (
<Drawer title="Save dashboard" subtitle={model.dashboard.state.title} scrollableContent onClose={model.onClose}>
<SaveDashboardDiff diff={diff} oldValue={initialSaveModel} newValue={changedSaveModel} />
</Drawer>
);
};
}

View File

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

View File

@ -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": ""
}

View File

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

View File

@ -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<DashboardLoaderState> {
private cache: Record<string, DashboardScene> = {};
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<DashboardScene> {
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<SceneGridItem | SceneGridRow> {
@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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