DashboardScene: Panel edit route basics (#74081)

* DashboardScene: Panel edit route basics

* remove unused file

* Removed some comments

* Minor fix

* Update

* example of apply changes implementation

* SceneObjectRef: Testing scene object ref

* Rename to ref suffix

* Update

* Fix url sync in panel edit

* Update

* Update

* simplify logic when committing change

* remove import

* Another fix for committing change
This commit is contained in:
Torkel Ödegaard 2023-09-11 13:51:05 +02:00 committed by GitHub
parent b9c681e1a7
commit 499b02b3c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 477 additions and 55 deletions

View File

@ -10,6 +10,7 @@ import {
SceneObject,
sceneGraph,
VizPanel,
SceneObjectRef,
} from '@grafana/scenes';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils';
@ -21,39 +22,38 @@ import { InspectTabState } from './types';
interface PanelInspectDrawerState extends SceneObjectState {
tabs?: Array<SceneObject<InspectTabState>>;
panelRef: SceneObjectRef<VizPanel>;
}
export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState> {
static Component = PanelInspectRenderer;
// Not stored in state as this is just a reference and it never changes
private _panel: VizPanel;
constructor(state: PanelInspectDrawerState) {
super(state);
constructor(panel: VizPanel) {
super({});
this._panel = panel;
this.buildTabs();
}
buildTabs() {
const plugin = this._panel.getPlugin();
const panel = this.state.panelRef.resolve();
const plugin = panel.getPlugin();
const tabs: Array<SceneObject<InspectTabState>> = [];
if (plugin) {
if (supportsDataQuery(plugin)) {
tabs.push(new InspectDataTab(this._panel));
tabs.push(new InspectStatsTab(this._panel));
tabs.push(new InspectDataTab(panel));
tabs.push(new InspectStatsTab(panel));
}
}
tabs.push(new InspectJsonTab(this._panel));
tabs.push(new InspectJsonTab(panel));
this.setState({ tabs });
}
getDrawerTitle() {
return sceneGraph.interpolate(this._panel, `Inspect: ${this._panel.state.title}`);
const panel = this.state.panelRef.resolve();
return sceneGraph.interpolate(panel, `Inspect: ${panel.state.title}`);
}
onClose = () => {

View File

@ -12,10 +12,11 @@ export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
export function DashboardScenePage({ match }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading } = stateManager.useState();
const { dashboard, isLoading, loadError } = stateManager.useState();
useEffect(() => {
stateManager.loadAndInit(match.params.uid);
stateManager.loadDashboard(match.params.uid);
return () => {
stateManager.clearState();
};
@ -25,7 +26,7 @@ export function DashboardScenePage({ match }: Props) {
return (
<Page layout={PageLayoutType.Canvas}>
{isLoading && <PageLoader />}
{!isLoading && <h2>Dashboard not found</h2>}
{loadError && <h2>{loadError}</h2>}
</Page>
);
}

View File

@ -12,12 +12,12 @@ describe('DashboardScenePageStateManager', () => {
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
// should use cache second time
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
expect(loadDashboardMock.mock.calls.length).toBe(1);
});
@ -25,7 +25,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: undefined, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.isLoading).toBe(false);
@ -36,7 +36,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
expect(loader.state.loadError).toBe(undefined);
@ -47,7 +47,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
expect(loader.state.isLoading).toBe(false);
@ -59,7 +59,7 @@ describe('DashboardScenePageStateManager', () => {
locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({});
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
@ -69,7 +69,7 @@ describe('DashboardScenePageStateManager', () => {
// try loading again (and hitting cache)
locationService.partial({ from: 'now-10m', to: 'now' });
await loader.loadAndInit('fake-dash');
await loader.loadDashboard('fake-dash');
const dash2 = loader.state.dashboard;
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');

View File

@ -1,11 +1,14 @@
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { buildPanelEditScene, PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { getVizPanelKeyForPanelId, findVizPanelByKey } from '../utils/utils';
export interface DashboardScenePageState {
dashboard?: DashboardScene;
panelEditor?: PanelEditor;
isLoading?: boolean;
loadError?: string;
}
@ -13,13 +16,31 @@ export interface DashboardScenePageState {
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
private cache: Record<string, DashboardScene> = {};
async loadAndInit(uid: string) {
public async loadDashboard(uid: string) {
try {
const scene = await this.loadScene(uid);
scene.startUrlSync();
const dashboard = await this.loadScene(uid);
dashboard.startUrlSync();
this.cache[uid] = scene;
this.setState({ dashboard: scene, isLoading: false });
this.setState({ dashboard: dashboard, isLoading: false });
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
}
}
public async loadPanelEdit(uid: string, panelId: string) {
try {
const dashboard = await this.loadScene(uid);
const panel = findVizPanelByKey(dashboard, getVizPanelKeyForPanelId(parseInt(panelId, 10)));
if (!panel) {
this.setState({ isLoading: false, loadError: 'Panel not found' });
return;
}
const panelEditor = buildPanelEditScene(dashboard, panel);
panelEditor.startUrlSync();
this.setState({ isLoading: false, panelEditor });
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
}
@ -36,14 +57,16 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
if (rsp.dashboard) {
return transformSaveModelToScene(rsp);
const scene = transformSaveModelToScene(rsp);
this.cache[uid] = scene;
return scene;
}
throw new Error('Dashboard not found');
}
public clearState() {
this.setState({ dashboard: undefined, loadError: undefined, isLoading: false });
this.setState({ dashboard: undefined, loadError: undefined, isLoading: false, panelEditor: undefined });
}
}

View File

@ -0,0 +1,36 @@
// Libraries
import React, { useEffect } from 'react';
import { PageLayoutType } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
export interface Props extends GrafanaRouteComponentProps<{ uid: string; panelId: string }> {}
export function PanelEditPage({ match }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { panelEditor, isLoading, loadError } = stateManager.useState();
useEffect(() => {
stateManager.loadPanelEdit(match.params.uid, match.params.panelId);
return () => {
stateManager.clearState();
};
}, [stateManager, match.params.uid, match.params.panelId]);
if (!panelEditor) {
return (
<Page layout={PageLayoutType.Canvas}>
{isLoading && <PageLoader />}
{loadError && <h2>{loadError}</h2>}
</Page>
);
}
return <panelEditor.Component model={panelEditor} />;
}
export default PanelEditPage;

View File

@ -0,0 +1,134 @@
import * as H from 'history';
import { locationService } from '@grafana/runtime';
import {
getUrlSyncManager,
SceneFlexItem,
SceneFlexLayout,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
sceneUtils,
SplitLayout,
VizPanel,
} from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { getDashboardUrl } from '../utils/utils';
import { PanelEditorRenderer } from './PanelEditorRenderer';
import { PanelOptionsPane } from './PanelOptionsPane';
export interface PanelEditorState extends SceneObjectState {
body: SceneObject;
controls?: SceneObject[];
isDirty?: boolean;
/** Panel to inspect */
inspectPanelId?: string;
/** Scene object that handles the current drawer */
drawer?: SceneObject;
dashboardRef: SceneObjectRef<DashboardScene>;
sourcePanelRef: SceneObjectRef<VizPanel>;
panelRef: SceneObjectRef<VizPanel>;
}
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
static Component = PanelEditorRenderer;
public constructor(state: PanelEditorState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
// Deactivation logic
return () => {
getUrlSyncManager().cleanUp(this);
};
}
public startUrlSync() {
getUrlSyncManager().initSync(this);
}
public getPageNav(location: H.Location) {
return {
text: 'Edit panel',
parentItem: this.state.dashboardRef.resolve().getPageNav(location),
};
}
public onDiscard = () => {
// Open question on what to preserve when going back
// Preserve time range, and variables state (that might have been changed while in panel edit)
// Preserve current panel data? (say if you just changed the time range and have new data)
this._navigateBackToDashboard();
};
public onApply = () => {
this._commitChanges();
this._navigateBackToDashboard();
};
public onSave = () => {
this._commitChanges();
// Open dashboard save drawer
};
private _commitChanges() {
const dashboard = this.state.dashboardRef.resolve();
const sourcePanel = this.state.sourcePanelRef.resolve();
const panel = this.state.panelRef.resolve();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
const newState = sceneUtils.cloneSceneObjectState(panel.state);
sourcePanel.setState(newState);
// preserve time range and variables state
dashboard.setState({
$timeRange: this.state.$timeRange?.clone(),
$variables: this.state.$variables?.clone(),
isDirty: true,
});
}
private _navigateBackToDashboard() {
locationService.push(
getDashboardUrl({
uid: this.state.dashboardRef.resolve().state.uid,
currentQueryParams: locationService.getLocation().search,
})
);
}
}
export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel): PanelEditor {
const panelClone = panel.clone();
const dashboardStateCloned = sceneUtils.cloneSceneObjectState(dashboard.state);
return new PanelEditor({
dashboardRef: new SceneObjectRef(dashboard),
sourcePanelRef: new SceneObjectRef(panel),
panelRef: new SceneObjectRef(panelClone),
controls: dashboardStateCloned.controls,
$variables: dashboardStateCloned.$variables,
$timeRange: dashboardStateCloned.$timeRange,
body: new SplitLayout({
direction: 'row',
primary: new SceneFlexLayout({
direction: 'column',
children: [panelClone],
}),
secondary: new SceneFlexItem({
width: '300px',
body: new PanelOptionsPane(panelClone),
}),
}),
});
}

View File

@ -0,0 +1,92 @@
import { css } from '@emotion/css';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { Button, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
import { Page } from 'app/core/components/Page/Page';
import { PanelEditor } from './PanelEditor';
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
const { body, controls, drawer } = model.useState();
const styles = useStyles2(getStyles);
const location = useLocation();
const pageNav = model.getPageNav(location);
return (
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
<AppChromeUpdate actions={getToolbarActions(model)} />
<div className={styles.canvasContent}>
{controls && (
<div className={styles.controls}>
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
</div>
)}
<div className={styles.body}>
<body.Component model={body} />
</div>
</div>
{drawer && <drawer.Component model={drawer} />}
</Page>
);
}
function getToolbarActions(editor: PanelEditor) {
return (
<>
<NavToolbarSeparator leftActionsSeparator key="separator" />
<Button
onClick={editor.onDiscard}
tooltip=""
key="panel-edit-discard"
variant="destructive"
fill="outline"
size="sm"
>
Discard
</Button>
<Button onClick={editor.onApply} tooltip="" key="panel-edit-apply" variant="primary" size="sm">
Apply
</Button>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
canvasContent: css({
label: 'canvas-content',
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(0, 2),
flexBasis: '100%',
flexGrow: 1,
minHeight: 0,
width: '100%',
}),
body: css({
label: 'body',
flexGrow: 1,
display: 'flex',
position: 'relative',
minHeight: 0,
gap: '8px',
marginBottom: theme.spacing(2),
}),
controls: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(2, 0),
}),
};
}

View File

@ -0,0 +1,45 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Field, Input, useStyles2 } from '@grafana/ui';
export interface PanelOptionsPaneState extends SceneObjectState {}
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
public panel: VizPanel;
public constructor(panel: VizPanel) {
super({});
this.panel = panel;
}
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
const { panel } = model;
const { title } = panel.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.box}>
<Field label="Title">
<Input value={title} onChange={(evt) => panel.setState({ title: evt.currentTarget.value })} />
</Field>
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
box: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
flexBasis: '100%',
flexGrow: 1,
minHeight: 0,
}),
};
}

View File

@ -55,6 +55,7 @@ describe('DashboardScene', () => {
function buildTestScene() {
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
body: new SceneGridLayout({
children: [
new SceneGridItem({

View File

@ -1,7 +1,7 @@
import * as H from 'history';
import { Unsubscribable } from 'rxjs';
import { locationUtil, NavModelItem, UrlQueryMap } from '@grafana/data';
import { NavModelItem, UrlQueryMap } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
getUrlSyncManager,
@ -9,6 +9,7 @@ import {
SceneGridLayout,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectStateChangedEvent,
sceneUtils,
@ -16,7 +17,7 @@ import {
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer';
import { findVizPanelByKey, forceRenderChildren } from '../utils/utils';
import { findVizPanelByKey, forceRenderChildren, getDashboardUrl } from '../utils/utils';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
@ -59,10 +60,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
public constructor(state: DashboardSceneState) {
super(state);
this.addActivationHandler(() => this.onActivate());
this.addActivationHandler(() => this._activationHandler());
}
private onActivate() {
private _activationHandler() {
if (this.state.isEditing) {
this.startTrackingChanges();
}
@ -119,13 +120,17 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
};
public onSave = () => {
this.setState({ drawer: new SaveDashboardDrawer(this) });
this.setState({ drawer: new SaveDashboardDrawer({ dashboardRef: new SceneObjectRef(this) }) });
};
public getPageNav(location: H.Location) {
let pageNav: NavModelItem = {
text: this.state.title,
url: locationUtil.getUrlForPartial(location, { viewPanel: null, inspect: null }),
url: getDashboardUrl({
uid: this.state.uid,
currentQueryParams: location.search,
updateQuery: { viewPanel: null, inspect: null },
}),
};
if (this.state.viewPanelKey) {

View File

@ -1,6 +1,6 @@
import { AppEvents } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes';
import { SceneObjectRef, SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
@ -34,7 +34,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
update.inspectPanelKey = values.inspect;
update.drawer = new PanelInspectDrawer(panel);
update.drawer = new PanelInspectDrawer({ panelRef: new SceneObjectRef(panel) });
} else if (inspectPanelId) {
update.inspectPanelKey = undefined;
update.drawer = undefined;

View File

@ -3,6 +3,10 @@ import { locationService } from '@grafana/runtime';
import { VizPanel, VizPanelMenu } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { getDashboardUrl, getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
/**
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
*/
@ -10,26 +14,45 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
// hm.. add another generic param to SceneObject to specify parent type?
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const panel = menu.parent as VizPanel;
const location = locationService.getLocation();
const items: PanelMenuItem[] = [];
const panelId = getPanelIdForVizPanel(panel);
const dashboard = panel.getRoot();
// TODO
// Add tracking via reportInteraction (but preserve the fact that these are normal links)
items.push({
text: t('panel.header-menu.view', `View`),
iconClassName: 'eye',
shortcut: 'v',
// Hm... need the numeric id to be url compatible?
href: locationUtil.getUrlForPartial(location, { viewPanel: panel.state.key }),
});
if (dashboard instanceof DashboardScene) {
items.push({
text: t('panel.header-menu.view', `View`),
iconClassName: 'eye',
shortcut: 'v',
href: getDashboardUrl({
uid: dashboard.state.uid,
currentQueryParams: location.search,
updateQuery: { filter: null, new: 'A' },
}),
});
// We could check isEditing here but I kind of think this should always be in the menu,
// and going into panel edit should make the dashboard go into edit mode is it's not already
items.push({
text: t('panel.header-menu.edit', `Edit`),
iconClassName: 'eye',
shortcut: 'v',
href: getDashboardUrl({
uid: dashboard.state.uid,
subPath: `/panel-edit/${panelId}`,
currentQueryParams: location.search,
updateQuery: { filter: null, new: 'A' },
}),
});
}
items.push({
text: t('panel.header-menu.inspect', `Inspect`),
iconClassName: 'info-circle',
shortcut: 'i',
// Hm... need the numeric id to be url compatible?
href: locationUtil.getUrlForPartial(location, { inspect: panel.state.key }),
});

View File

@ -1,6 +1,6 @@
import React from 'react';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } 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';
@ -9,21 +9,21 @@ import { DashboardScene } from '../scene/DashboardScene';
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
interface SaveDashboardDrawerState extends SceneObjectState {}
interface SaveDashboardDrawerState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>;
}
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
constructor(public dashboard: DashboardScene) {
super({});
}
onClose = () => {
this.dashboard.setState({ drawer: undefined });
this.state.dashboardRef.resolve().setState({ drawer: undefined });
};
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
const initialScene = new DashboardScene(model.dashboard.getInitialState()!);
const dashboard = model.state.dashboardRef.resolve();
const initialState = dashboard.getInitialState();
const initialScene = new DashboardScene(initialState!);
const initialSaveModel = transformSceneToSaveModel(initialScene);
const changedSaveModel = transformSceneToSaveModel(model.dashboard);
const changedSaveModel = transformSceneToSaveModel(dashboard);
const diff = jsonDiff(initialSaveModel, changedSaveModel);
@ -33,7 +33,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
// }
return (
<Drawer title="Save dashboard" subtitle={model.dashboard.state.title} scrollableContent onClose={model.onClose}>
<Drawer title="Save dashboard" subtitle={dashboard.state.title} scrollableContent onClose={model.onClose}>
<SaveDashboardDiff diff={diff} oldValue={initialSaveModel} newValue={changedSaveModel} />
</Drawer>
);

View File

@ -0,0 +1,25 @@
import { getDashboardUrl } from './utils';
describe('dashboard utils', () => {
it('Can getUrl', () => {
const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A' });
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A');
});
it('Can getUrl with subpath', () => {
const url = getDashboardUrl({ uid: 'dash-1', subPath: '/panel-edit/2', currentQueryParams: '?orgId=1&filter=A' });
expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A');
});
it('Can getUrl with params removed and addded', () => {
const url = getDashboardUrl({
uid: 'dash-1',
currentQueryParams: '?orgId=1&filter=A',
updateQuery: { filter: null, new: 'A' },
});
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A');
});
});

View File

@ -1,3 +1,5 @@
import { UrlQueryMap, urlUtil } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime';
import { MultiValueVariable, sceneGraph, SceneObject, VizPanel } from '@grafana/scenes';
export function getVizPanelKeyForPanelId(panelId: number) {
@ -67,6 +69,35 @@ export function forceRenderChildren(model: SceneObject, recursive?: boolean) {
});
}
export interface DashboardUrlOptions {
uid?: string;
subPath?: string;
updateQuery?: UrlQueryMap;
/**
* Set to location.search to preserve current params
*/
currentQueryParams: string;
}
export function getDashboardUrl(options: DashboardUrlOptions) {
const url = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`;
const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {};
if (options.updateQuery) {
for (const key of Object.keys(options.updateQuery)) {
// removing params with null | undefined
if (options.updateQuery[key] === null || options.updateQuery[key] === undefined) {
delete params[key];
} else {
params[key] = options.updateQuery[key];
}
}
}
return urlUtil.renderUrl(url, params);
}
export function getMultiVariableValues(variable: MultiValueVariable) {
const { value, text, options } = variable.state;

View File

@ -545,6 +545,12 @@ export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
() => import(/* webpackChunkName: "scenes"*/ 'app/features/dashboard-scene/pages/DashboardScenePage')
),
},
{
path: '/scenes/dashboard/:uid/panel-edit/:panelId',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "scenes"*/ 'app/features/dashboard-scene/pages/PanelEditPage')
),
},
{
path: '/scenes/grafana-monitoring',
exact: false,