From e7797ac4395b431715810315c3137b9e1913f509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 12 Jul 2023 13:37:26 +0200 Subject: [PATCH] SceneDashboard: Adds menu to panels, a start for inspect drawer state (#71194) * Began work on panel menu * SceneDashboard: Basic state handling for inspect panel * Switched to using scene url sync instead * Updated * Update comment on hack * Fixed url synnc issues and more / better DashboardsLoader tests * Progress on test * Updates * Progress * Progress * Update * Update * Update * Update * Update scenes lib * Update * Update --------- Co-authored-by: Dominik Prokop --- .betterer.results | 7 + .../test/__mocks__/pluginMocks.ts | 1 + .../scenes/dashboard/DashboardScene.test.tsx | 51 ++++++ .../scenes/dashboard/DashboardScene.tsx | 102 +++++++++++- .../dashboard/DashboardScenePage.test.tsx | 156 ++++++++++++++++++ .../scenes/dashboard/DashboardScenePage.tsx | 2 +- .../dashboard/DashboardSceneRenderer.tsx | 86 ++++------ .../scenes/dashboard/DashboardsLoader.test.ts | 105 +++++++----- .../scenes/dashboard/DashboardsLoader.ts | 64 +++---- .../scenes/dashboard/NavToolbarActions.tsx | 84 ++++++++++ .../scenes/dashboard/PanelMenuBehavior.tsx | 37 +++++ .../scenes/dashboard/ScenePanelInspector.tsx | 26 +++ .../features/scenes/dashboard/test-utils.ts | 40 +++++ public/test/jest-setup.ts | 30 ++++ 14 files changed, 658 insertions(+), 133 deletions(-) create mode 100644 public/app/features/scenes/dashboard/DashboardScene.test.tsx create mode 100644 public/app/features/scenes/dashboard/DashboardScenePage.test.tsx create mode 100644 public/app/features/scenes/dashboard/NavToolbarActions.tsx create mode 100644 public/app/features/scenes/dashboard/PanelMenuBehavior.tsx create mode 100644 public/app/features/scenes/dashboard/ScenePanelInspector.tsx create mode 100644 public/app/features/scenes/dashboard/test-utils.ts diff --git a/.betterer.results b/.betterer.results index 15af1449223..d18fb807162 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2991,6 +2991,13 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/scenes/dashboard/test-utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"] + ], "public/app/features/search/components/SearchCard.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/packages/grafana-data/test/__mocks__/pluginMocks.ts b/packages/grafana-data/test/__mocks__/pluginMocks.ts index de8b7fe7a50..71268a244c9 100644 --- a/packages/grafana-data/test/__mocks__/pluginMocks.ts +++ b/packages/grafana-data/test/__mocks__/pluginMocks.ts @@ -66,6 +66,7 @@ export function getPanelPlugin( hideFromList: options.hideFromList === true, module: options.module ?? '', baseUrl: '', + skipDataQuery: options.skipDataQuery, }; return plugin; } diff --git a/public/app/features/scenes/dashboard/DashboardScene.test.tsx b/public/app/features/scenes/dashboard/DashboardScene.test.tsx new file mode 100644 index 00000000000..bb27c46ba1d --- /dev/null +++ b/public/app/features/scenes/dashboard/DashboardScene.test.tsx @@ -0,0 +1,51 @@ +import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes'; + +import { DashboardScene } from './DashboardScene'; + +describe('DashboardScene', () => { + describe('Given a standard scene', () => { + it('Should set inspectPanelKey when url has inspect key', () => { + const scene = buildTestScene(); + scene.urlSync?.updateFromUrl({ inspect: 'panel-2' }); + expect(scene.state.inspectPanelKey).toBe('panel-2'); + }); + + it('Should handle inspect key that is not found', () => { + const scene = buildTestScene(); + scene.urlSync?.updateFromUrl({ inspect: '12321' }); + expect(scene.state.inspectPanelKey).toBe(undefined); + }); + + it('Should set viewPanelKey when url has viewPanel', () => { + const scene = buildTestScene(); + scene.urlSync?.updateFromUrl({ viewPanel: 'panel-2' }); + expect(scene.state.viewPanelKey).toBe('panel-2'); + }); + }); +}); + +function buildTestScene() { + const scene = new DashboardScene({ + title: 'hello', + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + ], + }), + }); + + return scene; +} diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 203bf398cb8..42154d1e0cc 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -1,11 +1,20 @@ +import * as H from 'history'; + +import { AppEvents, locationUtil, NavModelItem } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { getUrlSyncManager, + sceneGraph, SceneGridItem, SceneObject, SceneObjectBase, SceneObjectState, SceneObjectStateChangedEvent, + SceneObjectUrlSyncHandler, + SceneObjectUrlValues, + VizPanel, } from '@grafana/scenes'; +import appEvents from 'app/core/app_events'; import { DashboardSceneRenderer } from './DashboardSceneRenderer'; @@ -17,21 +26,42 @@ export interface DashboardSceneState extends SceneObjectState { controls?: SceneObject[]; isEditing?: boolean; isDirty?: boolean; + /** Scene object key for object to inspect */ + inspectPanelKey?: string; + /** Scene object key for object to view in fullscreen */ + viewPanelKey?: string; } export class DashboardScene extends SceneObjectBase { static Component = DashboardSceneRenderer; + protected _urlSync = new DashboardSceneUrlSync(this); + constructor(state: DashboardSceneState) { super(state); this.addActivationHandler(() => { - return () => getUrlSyncManager().cleanUp(this); + return () => { + getUrlSyncManager().cleanUp(this); + }; }); this.subscribeToEvent(SceneObjectStateChangedEvent, this.onChildStateChanged); } + findPanel(key: string | undefined): VizPanel | null { + if (!key) { + return null; + } + + const obj = sceneGraph.findObject(this, (obj) => obj.state.key === key); + if (obj instanceof VizPanel) { + return obj; + } + + return null; + } + onChildStateChanged = (event: SceneObjectStateChangedEvent) => { // Temporary hacky way to detect changes if (event.payload.changedObject instanceof SceneGridItem) { @@ -52,4 +82,74 @@ export class DashboardScene extends SceneObjectBase { // TODO actually discard changes this.setState({ isEditing: false }); }; + + onCloseInspectDrawer = () => { + locationService.partial({ inspect: null }); + }; + + getPageNav(location: H.Location) { + let pageNav: NavModelItem = { + text: this.state.title, + url: locationUtil.getUrlForPartial(location, { viewPanel: null, inspect: null }), + }; + + if (this.state.viewPanelKey) { + pageNav = { + text: 'View panel', + parentItem: pageNav, + }; + } + + return pageNav; + } +} + +class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { + constructor(private _scene: DashboardScene) {} + + getKeys(): string[] { + return ['inspect', 'viewPanel']; + } + + getUrlState(): SceneObjectUrlValues { + const state = this._scene.state; + return { inspect: state.inspectPanelKey, viewPanel: state.viewPanelKey }; + } + + updateFromUrl(values: SceneObjectUrlValues): void { + const { inspectPanelKey, viewPanelKey } = this._scene.state; + const update: Partial = {}; + + // Handle inspect object state + if (typeof values.inspect === 'string') { + const panel = this._scene.findPanel(values.inspect); + if (!panel) { + appEvents.emit(AppEvents.alertError, ['Panel not found']); + locationService.partial({ inspect: null }); + return; + } + + update.inspectPanelKey = values.inspect; + } else if (inspectPanelKey) { + update.inspectPanelKey = undefined; + } + + // Handle view panel state + if (typeof values.viewPanel === 'string') { + const panel = this._scene.findPanel(values.viewPanel); + if (!panel) { + appEvents.emit(AppEvents.alertError, ['Panel not found']); + locationService.partial({ viewPanel: null }); + return; + } + + update.viewPanelKey = values.viewPanel; + } else if (viewPanelKey) { + update.viewPanelKey = undefined; + } + + if (Object.keys(update).length > 0) { + this._scene.setState(update); + } + } } diff --git a/public/app/features/scenes/dashboard/DashboardScenePage.test.tsx b/public/app/features/scenes/dashboard/DashboardScenePage.test.tsx new file mode 100644 index 00000000000..b73674bc8be --- /dev/null +++ b/public/app/features/scenes/dashboard/DashboardScenePage.test.tsx @@ -0,0 +1,156 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { PanelProps } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { config, locationService, setPluginImportUtils } from '@grafana/runtime'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; + +import { DashboardScenePage, Props } from './DashboardScenePage'; +import { mockResizeObserver, setupLoadDashboardMock } from './test-utils'; + +function setup() { + const context = getGrafanaContextMock(); + const props: Props = { + ...getRouteComponentProps(), + }; + props.match.params.uid = 'd10'; + + const renderResult = render( + + + + ); + + return { renderResult, context }; +} + +const simpleDashboard = { + title: 'My cool dashboard', + uid: '10d', + panels: [ + { + id: 1, + type: 'custom-viz-panel', + title: 'Panel A', + options: { + content: `Content A`, + }, + gridPos: { + x: 0, + y: 0, + w: 10, + h: 10, + }, + targets: [], + }, + { + id: 2, + type: 'custom-viz-panel', + title: 'Panel B', + options: { + content: `Content B`, + }, + gridPos: { + x: 0, + y: 10, + w: 10, + h: 10, + }, + targets: [], + }, + ], +}; + +const panelPlugin = getPanelPlugin( + { + skipDataQuery: true, + }, + CustomVizPanel +); + +config.panels['custom-viz-panel'] = panelPlugin.meta; + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(panelPlugin), + getPanelPluginFromCache: (id: string) => undefined, +}); + +mockResizeObserver(); + +describe('DashboardScenePage', () => { + beforeEach(() => { + locationService.push('/'); + setupLoadDashboardMock({ dashboard: simpleDashboard, meta: {} }); + // hacky way because mocking autosizer does not work + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); + }); + + it('Can render dashboard', async () => { + setup(); + + await waitForDashbordToRender(); + + expect(await screen.findByTitle('Panel A')).toBeInTheDocument(); + expect(await screen.findByText('Content A')).toBeInTheDocument(); + + expect(await screen.findByTitle('Panel B')).toBeInTheDocument(); + expect(await screen.findByText('Content B')).toBeInTheDocument(); + }); + + it('Can inspect panel', async () => { + setup(); + + await waitForDashbordToRender(); + + expect(screen.queryByText('Inspect: Panel B')).not.toBeInTheDocument(); + + // Wish I could use the menu here but unable t get it to open when I click the menu button + // Somethig with Dropdown that is not working inside react-testing + await userEvent.click(screen.getByLabelText('Menu for panel with title Panel B')); + + const inspectLink = (await screen.findByRole('link', { name: /Inspect/ })).getAttribute('href')!; + act(() => locationService.push(inspectLink)); + + // I get not implemented exception here (from navigation / js-dom). + // Mocking window.location.assign did not help + //await userEvent.click(await screen.findByRole('link', { name: /Inspect/ })); + + expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument(); + + act(() => locationService.partial({ inspect: null })); + + expect(screen.queryByText('Inspect: Panel B')).not.toBeInTheDocument(); + }); + + it('Can view panel in fullscreen', async () => { + setup(); + + await waitForDashbordToRender(); + + expect(await screen.findByTitle('Panel A')).toBeInTheDocument(); + + act(() => locationService.partial({ viewPanel: 'panel-2' })); + + expect(screen.queryByTitle('Panel A')).not.toBeInTheDocument(); + expect(await screen.findByTitle('Panel B')).toBeInTheDocument(); + }); +}); + +interface VizOptions { + content: string; +} +interface VizProps extends PanelProps {} + +function CustomVizPanel(props: VizProps) { + return
{props.options.content}
; +} + +async function waitForDashbordToRender() { + expect(await screen.findByText('Last 6 hours')).toBeInTheDocument(); + expect(await screen.findByTitle('Panel A')).toBeInTheDocument(); +} diff --git a/public/app/features/scenes/dashboard/DashboardScenePage.tsx b/public/app/features/scenes/dashboard/DashboardScenePage.tsx index 426c5185ffe..e774cd75786 100644 --- a/public/app/features/scenes/dashboard/DashboardScenePage.tsx +++ b/public/app/features/scenes/dashboard/DashboardScenePage.tsx @@ -15,7 +15,7 @@ export const DashboardScenePage = ({ match }: Props) => { const { dashboard, isLoading } = loader.useState(); useEffect(() => { - loader.load(match.params.uid); + loader.loadAndInit(match.params.uid); return () => { loader.clearState(); }; diff --git a/public/app/features/scenes/dashboard/DashboardSceneRenderer.tsx b/public/app/features/scenes/dashboard/DashboardSceneRenderer.tsx index 3ba0e0adc21..cba4e03cca9 100644 --- a/public/app/features/scenes/dashboard/DashboardSceneRenderer.tsx +++ b/public/app/features/scenes/dashboard/DashboardSceneRenderer.tsx @@ -1,73 +1,29 @@ import { css } from '@emotion/css'; import React from 'react'; +import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; -import { Button, CustomScrollbar, useStyles2 } from '@grafana/ui'; -import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; -import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; +import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton'; import { DashboardScene } from './DashboardScene'; +import { NavToolbarActions } from './NavToolbarActions'; +import { ScenePanelInspector } from './ScenePanelInspector'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { - const { title, body, actions = [], controls, isEditing, isDirty, uid } = model.useState(); + const { body, controls, inspectPanelKey, viewPanelKey } = model.useState(); const styles = useStyles2(getStyles); - const toolbarActions = (actions ?? []).map((action) => ); - - if (uid) { - toolbarActions.push( - locationService.push(`/d/${uid}`)} - /> - ); - } - - toolbarActions.push(); - - if (!isEditing) { - // TODO check permissions - toolbarActions.push( - - ); - } else { - // TODO check permissions - toolbarActions.push( - - ); - toolbarActions.push( - - ); - toolbarActions.push( - - ); - } + const inspectPanel = model.findPanel(inspectPanelKey); + const viewPanel = model.findPanel(viewPanelKey); + const location = useLocation(); + const pageNav = model.getPageNav(location); return ( - +
- + {controls && (
{controls.map((control) => ( @@ -75,11 +31,18 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps )} -
- -
+ {viewPanel ? ( +
+ +
+ ) : ( +
+ +
+ )}
+ {inspectPanel && } ); } @@ -95,10 +58,17 @@ function getStyles(theme: GrafanaTheme2) { flexGrow: 1, }), body: css({ + label: 'body', flexGrow: 1, display: 'flex', gap: '8px', }), + viewPanel: css({ + display: 'flex', + position: 'relative', + flexGrow: 1, + marginBottom: theme.spacing(2), + }), controls: css({ display: 'flex', flexWrap: 'wrap', diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts index f99c01732c4..2085d88c83a 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts @@ -1,6 +1,9 @@ +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { config, locationService } from '@grafana/runtime'; import { CustomVariable, DataSourceVariable, + getUrlSyncManager, QueryVariable, SceneDataTransformer, SceneGridItem, @@ -10,7 +13,6 @@ import { VizPanel, } from '@grafana/scenes'; import { defaultDashboard, LoadingState, Panel, RowPanel, VariableType } from '@grafana/schema'; -import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; @@ -24,6 +26,7 @@ import { DashboardLoader, } from './DashboardsLoader'; import { ShareQueryDataProvider } from './ShareQueryDataProvider'; +import { setupLoadDashboardMock } from './test-utils'; describe('DashboardLoader', () => { describe('when fetching/loading a dashboard', () => { @@ -31,76 +34,72 @@ describe('DashboardLoader', () => { new DashboardLoader({}); }); - it('should load the dashboard from the cache if it exists', () => { - const loader = new DashboardLoader({}); - const dashboard = new DashboardScene({ - title: 'cached', - uid: 'fake-uid', - body: new SceneGridLayout({ children: [] }), - }); - // @ts-expect-error - loader.cache['fake-uid'] = dashboard; - loader.load('fake-uid'); - expect(loader.state.dashboard).toBe(dashboard); - expect(loader.state.isLoading).toBe(undefined); - }); - it('should call dashboard loader server if the dashboard is not cached', async () => { - const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); - setDashboardLoaderSrv({ - loadDashboard: loadDashboardMock, - } as unknown as DashboardLoaderSrv); + const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loader = new DashboardLoader({}); - await loader.load('fake-dash'); + await loader.loadAndInit('fake-dash'); expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); + + // should use cache second time + await loader.loadAndInit('fake-dash'); + expect(loadDashboardMock.mock.calls.length).toBe(1); }); it("should error when the dashboard doesn't exist", async () => { - const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: undefined, meta: undefined }); - setDashboardLoaderSrv({ - loadDashboard: loadDashboardMock, - } as unknown as DashboardLoaderSrv); + setupLoadDashboardMock({ dashboard: undefined, meta: {} }); const loader = new DashboardLoader({}); - await loader.load('fake-dash'); + await loader.loadAndInit('fake-dash'); expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.isLoading).toBe(false); - // @ts-expect-error - private - expect(loader.cache['fake-dash']).toBeUndefined(); expect(loader.state.loadError).toBe('Error: Dashboard not found'); }); it('should initialize the dashboard scene with the loaded dashboard', async () => { - const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); - setDashboardLoaderSrv({ - loadDashboard: loadDashboardMock, - } as unknown as DashboardLoaderSrv); + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loader = new DashboardLoader({}); - await loader.load('fake-dash'); + await loader.loadAndInit('fake-dash'); expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); expect(loader.state.loadError).toBe(undefined); expect(loader.state.isLoading).toBe(false); - // It updates the cache - // @ts-expect-error - private - expect(loader.cache['fake-dash']).toBeDefined(); }); it('should use DashboardScene creator to initialize the scene', async () => { - const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); - setDashboardLoaderSrv({ - loadDashboard: loadDashboardMock, - } as unknown as DashboardLoaderSrv); + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loader = new DashboardLoader({}); - await loader.load('fake-dash'); + await loader.loadAndInit('fake-dash'); + expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); expect(loader.state.isLoading).toBe(false); }); + + it('should initialize url sync', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + locationService.partial({ from: 'now-5m', to: 'now' }); + + const loader = new DashboardLoader({}); + await loader.loadAndInit('fake-dash'); + const dash = loader.state.dashboard; + + expect(dash!.state.$timeRange?.state.from).toEqual('now-5m'); + + getUrlSyncManager().cleanUp(dash!); + + // try loading again (and hitting cache) + locationService.partial({ from: 'now-10m', to: 'now' }); + + await loader.loadAndInit('fake-dash'); + const dash2 = loader.state.dashboard; + + expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); + }); }); describe('when creating dashboard scene', () => { @@ -324,10 +323,11 @@ describe('DashboardLoader', () => { transparent: true, }; - const vizPanelSceneObject = createVizPanelFromPanelModel(new PanelModel(panel)); + const gridItem = createVizPanelFromPanelModel(new PanelModel(panel)); + const vizPanel = gridItem.state.body as VizPanel; - expect((vizPanelSceneObject.state.body as VizPanel)?.state.displayMode).toEqual('transparent'); - expect((vizPanelSceneObject.state.body as VizPanel)?.state.hoverHeader).toEqual(true); + expect(vizPanel.state.displayMode).toEqual('transparent'); + expect(vizPanel.state.hoverHeader).toEqual(true); }); it('should handle a dashboard query data source', () => { @@ -344,6 +344,25 @@ describe('DashboardLoader', () => { expect(vizPanel.state.$data).toBeInstanceOf(ShareQueryDataProvider); }); + + it('should not set SceneQueryRunner for plugins with skipDataQuery', () => { + const panel = { + title: '', + type: 'text-plugin-34', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + transparent: true, + targets: [{ refId: 'A' }], + }; + + config.panels['text-plugin-34'] = getPanelPlugin({ + skipDataQuery: true, + }).meta; + + const gridItem = createVizPanelFromPanelModel(new PanelModel(panel)); + const vizPanel = gridItem.state.body as VizPanel; + + expect(vizPanel.state.$data).toBeUndefined(); + }); }); describe('when creating variables objects', () => { diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index bca6a8f5f1b..a661f5b82a0 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -5,6 +5,7 @@ import { QueryVariableModel, VariableModel, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { VizPanel, SceneTimePicker, @@ -23,17 +24,17 @@ import { SceneDataTransformer, SceneGridItem, SceneDataProvider, - getUrlSyncManager, SceneObject, SceneControlsSpacer, + VizPanelMenu, } from '@grafana/scenes'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/types'; -import { DashboardDTO } from 'app/types'; import { DashboardScene } from './DashboardScene'; +import { panelMenuBehavior } from './PanelMenuBehavior'; import { ShareQueryDataProvider } from './ShareQueryDataProvider'; import { getVizPanelKeyForPanelId } from './utils'; @@ -46,42 +47,38 @@ export interface DashboardLoaderState { export class DashboardLoader extends StateManagerBase { private cache: Record = {}; - async load(uid: string) { - const fromCache = this.cache[uid]; - if (fromCache) { - this.setState({ dashboard: fromCache }); - return; - } - - this.setState({ isLoading: true }); - + async loadAndInit(uid: string) { try { - const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + const scene = await this.loadScene(uid); + scene.initUrlSync(); - if (rsp.dashboard) { - this.initDashboard(rsp); - } else { - throw new Error('Dashboard not found'); - } + this.cache[uid] = scene; + this.setState({ dashboard: scene, isLoading: false }); } catch (err) { this.setState({ isLoading: false, loadError: String(err) }); } } - private initDashboard(rsp: DashboardDTO) { - // Just to have migrations run - const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { - autoMigrateOldPanels: true, - }); + private async loadScene(uid: string): Promise { + const fromCache = this.cache[uid]; + if (fromCache) { + return fromCache; + } - const dashboard = createDashboardSceneFromDashboardModel(oldModel); + this.setState({ isLoading: true }); - // We initialize URL sync here as it better to do that before mounting and doing any rendering. - // But would be nice to have a conditional around this so you can pre-load dashboards without url sync. - getUrlSyncManager().initSync(dashboard); + const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); - this.cache[rsp.dashboard.uid] = dashboard; - this.setState({ dashboard, isLoading: false }); + if (rsp.dashboard) { + // Just to have migrations run + const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { + autoMigrateOldPanels: true, + }); + + return createDashboardSceneFromDashboardModel(oldModel); + } + + throw new Error('Dashboard not found'); } clearState() { @@ -272,8 +269,6 @@ export function createVizPanelFromPanelModel(panel: PanelModel) { y: panel.gridPos.y, width: panel.gridPos.w, height: panel.gridPos.h, - isDraggable: true, - isResizable: true, body: new VizPanel({ key: getVizPanelKeyForPanelId(panel.id), title: panel.title, @@ -285,15 +280,24 @@ export function createVizPanelFromPanelModel(panel: PanelModel) { // To be replaced with it's own option persited option instead derived hoverHeader: !panel.title && !panel.timeFrom && !panel.timeShift, $data: createPanelDataProvider(panel), + menu: new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }), }), }); } export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | undefined { + // Skip setting query runner for panels without queries if (!panel.targets?.length) { return undefined; } + // Skip setting query runner for panel plugins with skipDataQuery + if (config.panels[panel.type]?.skipDataQuery) { + return undefined; + } + let dataProvider: SceneDataProvider | undefined = undefined; if (panel.datasource?.uid === SHARED_DASHBOARD_QUERY) { diff --git a/public/app/features/scenes/dashboard/NavToolbarActions.tsx b/public/app/features/scenes/dashboard/NavToolbarActions.tsx new file mode 100644 index 00000000000..158c0424bd1 --- /dev/null +++ b/public/app/features/scenes/dashboard/NavToolbarActions.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { locationService } from '@grafana/runtime'; +import { Button } from '@grafana/ui'; +import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; +import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; +import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton'; + +import { DashboardScene } from './DashboardScene'; + +interface Props { + dashboard: DashboardScene; +} + +export const NavToolbarActions = React.memo(({ dashboard }) => { + const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState(); + const toolbarActions = (actions ?? []).map((action) => ); + + if (uid) { + toolbarActions.push( + locationService.push(`/d/${uid}`)} + /> + ); + } + + toolbarActions.push(); + + if (viewPanelKey) { + toolbarActions.push( + + ); + + return ; + } + + if (!isEditing) { + // TODO check permissions + toolbarActions.push( + + ); + } else { + // TODO check permissions + toolbarActions.push( + + ); + toolbarActions.push( + + ); + toolbarActions.push( + + ); + } + + return ; +}); + +NavToolbarActions.displayName = 'NavToolbarActions'; diff --git a/public/app/features/scenes/dashboard/PanelMenuBehavior.tsx b/public/app/features/scenes/dashboard/PanelMenuBehavior.tsx new file mode 100644 index 00000000000..e2c65d698e1 --- /dev/null +++ b/public/app/features/scenes/dashboard/PanelMenuBehavior.tsx @@ -0,0 +1,37 @@ +import { locationUtil, PanelMenuItem } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { VizPanel, VizPanelMenu } from '@grafana/scenes'; +import { t } from 'app/core/internationalization'; + +/** + * Behavior is called when VizPanelMenu is activated (ie when it's opened). + */ +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[] = []; + + // 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 }), + }); + + 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 }), + }); + + menu.setState({ items }); +} diff --git a/public/app/features/scenes/dashboard/ScenePanelInspector.tsx b/public/app/features/scenes/dashboard/ScenePanelInspector.tsx new file mode 100644 index 00000000000..2bde4efd89a --- /dev/null +++ b/public/app/features/scenes/dashboard/ScenePanelInspector.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { VizPanel } from '@grafana/scenes'; +import { Drawer } from '@grafana/ui'; + +import { DashboardScene } from './DashboardScene'; + +interface Props { + dashboard: DashboardScene; + panel: VizPanel; +} + +export const ScenePanelInspector = React.memo(({ panel, dashboard }) => { + return ( + + Magic content + + ); +}); + +ScenePanelInspector.displayName = 'ScenePanelInspector'; diff --git a/public/app/features/scenes/dashboard/test-utils.ts b/public/app/features/scenes/dashboard/test-utils.ts new file mode 100644 index 00000000000..5a6c02946d4 --- /dev/null +++ b/public/app/features/scenes/dashboard/test-utils.ts @@ -0,0 +1,40 @@ +import { DeepPartial } from '@grafana/scenes'; +import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { DashboardDTO } from 'app/types'; + +export function setupLoadDashboardMock(rsp: DeepPartial) { + const loadDashboardMock = jest.fn().mockResolvedValue(rsp); + setDashboardLoaderSrv({ + loadDashboard: loadDashboardMock, + } as unknown as DashboardLoaderSrv); + return loadDashboardMock; +} + +export function mockResizeObserver() { + (window as any).ResizeObserver = class ResizeObserver { + constructor(callback: ResizeObserverCallback) { + setTimeout(() => { + callback( + [ + { + contentRect: { + x: 1, + y: 2, + width: 500, + height: 500, + top: 100, + bottom: 0, + left: 100, + right: 0, + }, + } as ResizeObserverEntry, + ], + this + ); + }); + } + observe() {} + disconnect() {} + unobserve() {} + }; +} diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index 6c3102dd328..cfcaf1e40bf 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -76,3 +76,33 @@ const throwUnhandledRejections = () => { }; throwUnhandledRejections(); + +// Used by useMeasure +global.ResizeObserver = class ResizeObserver { + //callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + setTimeout(() => { + callback( + [ + { + contentRect: { + x: 1, + y: 2, + width: 500, + height: 500, + top: 100, + bottom: 0, + left: 100, + right: 0, + }, + } as ResizeObserverEntry, + ], + this + ); + }); + } + observe() {} + disconnect() {} + unobserve() {} +};