mirror of https://github.com/grafana/grafana.git
				
				
				
			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:
		
							parent
							
								
									b9c681e1a7
								
							
						
					
					
						commit
						499b02b3c6
					
				| 
						 | 
				
			
			@ -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 = () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
      }),
 | 
			
		||||
    }),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -55,6 +55,7 @@ describe('DashboardScene', () => {
 | 
			
		|||
function buildTestScene() {
 | 
			
		||||
  const scene = new DashboardScene({
 | 
			
		||||
    title: 'hello',
 | 
			
		||||
    uid: 'dash-1',
 | 
			
		||||
    body: new SceneGridLayout({
 | 
			
		||||
      children: [
 | 
			
		||||
        new SceneGridItem({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
 | 
			
		||||
  if (dashboard instanceof DashboardScene) {
 | 
			
		||||
    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 }),
 | 
			
		||||
      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 }),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {}
 | 
			
		||||
 | 
			
		||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
 | 
			
		||||
  constructor(public dashboard: DashboardScene) {
 | 
			
		||||
    super({});
 | 
			
		||||
interface SaveDashboardDrawerState extends SceneObjectState {
 | 
			
		||||
  dashboardRef: SceneObjectRef<DashboardScene>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
 | 
			
		||||
  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>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue