diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index c447e919151..940a7849a71 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -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>; + panelRef: SceneObjectRef; } export class PanelInspectDrawer extends SceneObjectBase { 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> = []; 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 = () => { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 031c49fd529..5c27e377406 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -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 ( {isLoading && } - {!isLoading &&

Dashboard not found

} + {loadError &&

{loadError}

}
); } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 47933b27bcc..f0a8eef1a0c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -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'); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index c4ebb0866a8..e3bc9c76c8c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 { private cache: Record = {}; - 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 {} + +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 ( + + {isLoading && } + {loadError &&

{loadError}

} +
+ ); + } + + return ; +} + +export default PanelEditPage; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx new file mode 100644 index 00000000000..2a5f0ab299a --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -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; + sourcePanelRef: SceneObjectRef; + panelRef: SceneObjectRef; +} + +export class PanelEditor extends SceneObjectBase { + 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), + }), + }), + }); +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx new file mode 100644 index 00000000000..9af29338ca7 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -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) { + const { body, controls, drawer } = model.useState(); + const styles = useStyles2(getStyles); + const location = useLocation(); + const pageNav = model.getPageNav(location); + + return ( + + +
+ {controls && ( +
+ {controls.map((control) => ( + + ))} +
+ )} +
+ +
+
+ {drawer && } +
+ ); +} + +function getToolbarActions(editor: PanelEditor) { + return ( + <> + + + + + + + ); +} + +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), + }), + }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx new file mode 100644 index 00000000000..32ca6be4802 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -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 { + public panel: VizPanel; + + public constructor(panel: VizPanel) { + super({}); + + this.panel = panel; + } + + static Component = ({ model }: SceneComponentProps) => { + const { panel } = model; + const { title } = panel.useState(); + const styles = useStyles2(getStyles); + + return ( +
+ + panel.setState({ title: evt.currentTarget.value })} /> + +
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + box: css({ + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2), + flexBasis: '100%', + flexGrow: 1, + minHeight: 0, + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index b4cad393c7a..c8293801ae6 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -55,6 +55,7 @@ describe('DashboardScene', () => { function buildTestScene() { const scene = new DashboardScene({ title: 'hello', + uid: 'dash-1', body: new SceneGridLayout({ children: [ new SceneGridItem({ diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 882cdca0d96..21501f86efb 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 { 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 { }; 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) { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 23aa21e68e4..aa344b4cea2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -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; diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index e2c65d698e1..e60df4ed57a 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -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 }), }); diff --git a/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx index 69a1de1ed0e..6aa8098565a 100644 --- a/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/serialization/SaveDashboardDrawer.tsx @@ -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; +} export class SaveDashboardDrawer extends SceneObjectBase { - constructor(public dashboard: DashboardScene) { - super({}); - } - onClose = () => { - this.dashboard.setState({ drawer: undefined }); + this.state.dashboardRef.resolve().setState({ drawer: undefined }); }; static Component = ({ model }: SceneComponentProps) => { - 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 + ); diff --git a/public/app/features/dashboard-scene/utils/utils.test.ts b/public/app/features/dashboard-scene/utils/utils.test.ts new file mode 100644 index 00000000000..fb06896a58b --- /dev/null +++ b/public/app/features/dashboard-scene/utils/utils.test.ts @@ -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'); + }); +}); diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index c6fabed5e62..17bece74f12 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index c8d614bf752..dce15c3584e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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,