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 <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard
2023-07-12 13:37:26 +02:00
committed by GitHub
parent dcaab9bf4d
commit e7797ac439
14 changed files with 658 additions and 133 deletions

View File

@@ -2991,6 +2991,13 @@ exports[`better eslint`] = {
"public/app/features/sandbox/TestStuffPage.tsx:5381": [ "public/app/features/sandbox/TestStuffPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [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": [ "public/app/features/search/components/SearchCard.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "1"],

View File

@@ -66,6 +66,7 @@ export function getPanelPlugin(
hideFromList: options.hideFromList === true, hideFromList: options.hideFromList === true,
module: options.module ?? '', module: options.module ?? '',
baseUrl: '', baseUrl: '',
skipDataQuery: options.skipDataQuery,
}; };
return plugin; return plugin;
} }

View File

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

View File

@@ -1,11 +1,20 @@
import * as H from 'history';
import { AppEvents, locationUtil, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { import {
getUrlSyncManager, getUrlSyncManager,
sceneGraph,
SceneGridItem, SceneGridItem,
SceneObject, SceneObject,
SceneObjectBase, SceneObjectBase,
SceneObjectState, SceneObjectState,
SceneObjectStateChangedEvent, SceneObjectStateChangedEvent,
SceneObjectUrlSyncHandler,
SceneObjectUrlValues,
VizPanel,
} from '@grafana/scenes'; } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { DashboardSceneRenderer } from './DashboardSceneRenderer'; import { DashboardSceneRenderer } from './DashboardSceneRenderer';
@@ -17,21 +26,42 @@ export interface DashboardSceneState extends SceneObjectState {
controls?: SceneObject[]; controls?: SceneObject[];
isEditing?: boolean; isEditing?: boolean;
isDirty?: 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<DashboardSceneState> { export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
static Component = DashboardSceneRenderer; static Component = DashboardSceneRenderer;
protected _urlSync = new DashboardSceneUrlSync(this);
constructor(state: DashboardSceneState) { constructor(state: DashboardSceneState) {
super(state); super(state);
this.addActivationHandler(() => { this.addActivationHandler(() => {
return () => getUrlSyncManager().cleanUp(this); return () => {
getUrlSyncManager().cleanUp(this);
};
}); });
this.subscribeToEvent(SceneObjectStateChangedEvent, this.onChildStateChanged); 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) => { onChildStateChanged = (event: SceneObjectStateChangedEvent) => {
// Temporary hacky way to detect changes // Temporary hacky way to detect changes
if (event.payload.changedObject instanceof SceneGridItem) { if (event.payload.changedObject instanceof SceneGridItem) {
@@ -52,4 +82,74 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
// TODO actually discard changes // TODO actually discard changes
this.setState({ isEditing: false }); 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<DashboardSceneState> = {};
// 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);
}
}
} }

View File

@@ -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(
<TestProvider grafanaContext={context}>
<DashboardScenePage {...props} />
</TestProvider>
);
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<VizOptions> {}
function CustomVizPanel(props: VizProps) {
return <div>{props.options.content}</div>;
}
async function waitForDashbordToRender() {
expect(await screen.findByText('Last 6 hours')).toBeInTheDocument();
expect(await screen.findByTitle('Panel A')).toBeInTheDocument();
}

View File

@@ -15,7 +15,7 @@ export const DashboardScenePage = ({ match }: Props) => {
const { dashboard, isLoading } = loader.useState(); const { dashboard, isLoading } = loader.useState();
useEffect(() => { useEffect(() => {
loader.load(match.params.uid); loader.loadAndInit(match.params.uid);
return () => { return () => {
loader.clearState(); loader.clearState();
}; };

View File

@@ -1,73 +1,29 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps } from '@grafana/scenes';
import { Button, CustomScrollbar, useStyles2 } from '@grafana/ui'; import { CustomScrollbar, 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 { Page } from 'app/core/components/Page/Page';
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { DashboardScene } from './DashboardScene'; import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
import { ScenePanelInspector } from './ScenePanelInspector';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) { export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { title, body, actions = [], controls, isEditing, isDirty, uid } = model.useState(); const { body, controls, inspectPanelKey, viewPanelKey } = model.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />); const inspectPanel = model.findPanel(inspectPanelKey);
const viewPanel = model.findPanel(viewPanelKey);
if (uid) { const location = useLocation();
toolbarActions.push( const pageNav = model.getPageNav(location);
<DashNavButton
key="button-scenes"
tooltip={'View as dashboard'}
icon="apps"
onClick={() => locationService.push(`/d/${uid}`)}
/>
);
}
toolbarActions.push(<NavToolbarSeparator leftActionsSeparator />);
if (!isEditing) {
// TODO check permissions
toolbarActions.push(
<Button
onClick={model.onEnterEditMode}
tooltip="Enter edit mode"
key="edit"
variant="primary"
icon="pen"
fill="text"
>
Edit
</Button>
);
} else {
// TODO check permissions
toolbarActions.push(
<Button onClick={model.onEnterEditMode} tooltip="Save as copy" fill="text" key="save-as">
Save as
</Button>
);
toolbarActions.push(
<Button onClick={model.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
Discard
</Button>
);
toolbarActions.push(
<Button onClick={model.onEnterEditMode} tooltip="Save changes" key="save" disabled={!isDirty}>
Save
</Button>
);
}
return ( return (
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Custom}> <Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
<CustomScrollbar autoHeightMin={'100%'}> <CustomScrollbar autoHeightMin={'100%'}>
<div className={styles.canvasContent}> <div className={styles.canvasContent}>
<AppChromeUpdate actions={toolbarActions} /> <NavToolbarActions dashboard={model} />
{controls && ( {controls && (
<div className={styles.controls}> <div className={styles.controls}>
{controls.map((control) => ( {controls.map((control) => (
@@ -75,11 +31,18 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
))} ))}
</div> </div>
)} )}
<div className={styles.body}> {viewPanel ? (
<body.Component model={body} /> <div className={styles.viewPanel}>
</div> <viewPanel.Component model={viewPanel} />
</div>
) : (
<div className={styles.body}>
<body.Component model={body} />
</div>
)}
</div> </div>
</CustomScrollbar> </CustomScrollbar>
{inspectPanel && <ScenePanelInspector panel={inspectPanel} dashboard={model} />}
</Page> </Page>
); );
} }
@@ -95,10 +58,17 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1, flexGrow: 1,
}), }),
body: css({ body: css({
label: 'body',
flexGrow: 1, flexGrow: 1,
display: 'flex', display: 'flex',
gap: '8px', gap: '8px',
}), }),
viewPanel: css({
display: 'flex',
position: 'relative',
flexGrow: 1,
marginBottom: theme.spacing(2),
}),
controls: css({ controls: css({
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',

View File

@@ -1,6 +1,9 @@
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config, locationService } from '@grafana/runtime';
import { import {
CustomVariable, CustomVariable,
DataSourceVariable, DataSourceVariable,
getUrlSyncManager,
QueryVariable, QueryVariable,
SceneDataTransformer, SceneDataTransformer,
SceneGridItem, SceneGridItem,
@@ -10,7 +13,6 @@ import {
VizPanel, VizPanel,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { defaultDashboard, LoadingState, Panel, RowPanel, VariableType } from '@grafana/schema'; 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 { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
@@ -24,6 +26,7 @@ import {
DashboardLoader, DashboardLoader,
} from './DashboardsLoader'; } from './DashboardsLoader';
import { ShareQueryDataProvider } from './ShareQueryDataProvider'; import { ShareQueryDataProvider } from './ShareQueryDataProvider';
import { setupLoadDashboardMock } from './test-utils';
describe('DashboardLoader', () => { describe('DashboardLoader', () => {
describe('when fetching/loading a dashboard', () => { describe('when fetching/loading a dashboard', () => {
@@ -31,76 +34,72 @@ describe('DashboardLoader', () => {
new 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 () => { it('should call dashboard loader server if the dashboard is not cached', async () => {
const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
} as unknown as DashboardLoaderSrv);
const loader = new DashboardLoader({}); const loader = new DashboardLoader({});
await loader.load('fake-dash'); await loader.loadAndInit('fake-dash');
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', '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 () => { it("should error when the dashboard doesn't exist", async () => {
const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: undefined, meta: undefined }); setupLoadDashboardMock({ dashboard: undefined, meta: {} });
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
} as unknown as DashboardLoaderSrv);
const loader = new DashboardLoader({}); const loader = new DashboardLoader({});
await loader.load('fake-dash'); await loader.loadAndInit('fake-dash');
expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.isLoading).toBe(false); 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'); expect(loader.state.loadError).toBe('Error: Dashboard not found');
}); });
it('should initialize the dashboard scene with the loaded dashboard', async () => { it('should initialize the dashboard scene with the loaded dashboard', async () => {
const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
} as unknown as DashboardLoaderSrv);
const loader = new DashboardLoader({}); 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.dashboard?.state.uid).toBe('fake-dash');
expect(loader.state.loadError).toBe(undefined); expect(loader.state.loadError).toBe(undefined);
expect(loader.state.isLoading).toBe(false); 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 () => { it('should use DashboardScene creator to initialize the scene', async () => {
const loadDashboardMock = jest.fn().mockResolvedValue({ dashboard: { uid: 'fake-dash' }, meta: {} }); setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
} as unknown as DashboardLoaderSrv);
const loader = new DashboardLoader({}); const loader = new DashboardLoader({});
await loader.load('fake-dash'); await loader.loadAndInit('fake-dash');
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
expect(loader.state.isLoading).toBe(false); 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', () => { describe('when creating dashboard scene', () => {
@@ -324,10 +323,11 @@ describe('DashboardLoader', () => {
transparent: true, 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(vizPanel.state.displayMode).toEqual('transparent');
expect((vizPanelSceneObject.state.body as VizPanel)?.state.hoverHeader).toEqual(true); expect(vizPanel.state.hoverHeader).toEqual(true);
}); });
it('should handle a dashboard query data source', () => { it('should handle a dashboard query data source', () => {
@@ -344,6 +344,25 @@ describe('DashboardLoader', () => {
expect(vizPanel.state.$data).toBeInstanceOf(ShareQueryDataProvider); 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', () => { describe('when creating variables objects', () => {

View File

@@ -5,6 +5,7 @@ import {
QueryVariableModel, QueryVariableModel,
VariableModel, VariableModel,
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime';
import { import {
VizPanel, VizPanel,
SceneTimePicker, SceneTimePicker,
@@ -23,17 +24,17 @@ import {
SceneDataTransformer, SceneDataTransformer,
SceneGridItem, SceneGridItem,
SceneDataProvider, SceneDataProvider,
getUrlSyncManager,
SceneObject, SceneObject,
SceneControlsSpacer, SceneControlsSpacer,
VizPanelMenu,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/types'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/types';
import { DashboardDTO } from 'app/types';
import { DashboardScene } from './DashboardScene'; import { DashboardScene } from './DashboardScene';
import { panelMenuBehavior } from './PanelMenuBehavior';
import { ShareQueryDataProvider } from './ShareQueryDataProvider'; import { ShareQueryDataProvider } from './ShareQueryDataProvider';
import { getVizPanelKeyForPanelId } from './utils'; import { getVizPanelKeyForPanelId } from './utils';
@@ -46,42 +47,38 @@ export interface DashboardLoaderState {
export class DashboardLoader extends StateManagerBase<DashboardLoaderState> { export class DashboardLoader extends StateManagerBase<DashboardLoaderState> {
private cache: Record<string, DashboardScene> = {}; private cache: Record<string, DashboardScene> = {};
async load(uid: string) { async loadAndInit(uid: string) {
const fromCache = this.cache[uid];
if (fromCache) {
this.setState({ dashboard: fromCache });
return;
}
this.setState({ isLoading: true });
try { try {
const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); const scene = await this.loadScene(uid);
scene.initUrlSync();
if (rsp.dashboard) { this.cache[uid] = scene;
this.initDashboard(rsp); this.setState({ dashboard: scene, isLoading: false });
} else {
throw new Error('Dashboard not found');
}
} catch (err) { } catch (err) {
this.setState({ isLoading: false, loadError: String(err) }); this.setState({ isLoading: false, loadError: String(err) });
} }
} }
private initDashboard(rsp: DashboardDTO) { private async loadScene(uid: string): Promise<DashboardScene> {
// Just to have migrations run const fromCache = this.cache[uid];
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, { if (fromCache) {
autoMigrateOldPanels: true, 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. const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
// But would be nice to have a conditional around this so you can pre-load dashboards without url sync.
getUrlSyncManager().initSync(dashboard);
this.cache[rsp.dashboard.uid] = dashboard; if (rsp.dashboard) {
this.setState({ dashboard, isLoading: false }); // 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() { clearState() {
@@ -272,8 +269,6 @@ export function createVizPanelFromPanelModel(panel: PanelModel) {
y: panel.gridPos.y, y: panel.gridPos.y,
width: panel.gridPos.w, width: panel.gridPos.w,
height: panel.gridPos.h, height: panel.gridPos.h,
isDraggable: true,
isResizable: true,
body: new VizPanel({ body: new VizPanel({
key: getVizPanelKeyForPanelId(panel.id), key: getVizPanelKeyForPanelId(panel.id),
title: panel.title, title: panel.title,
@@ -285,15 +280,24 @@ export function createVizPanelFromPanelModel(panel: PanelModel) {
// To be replaced with it's own option persited option instead derived // To be replaced with it's own option persited option instead derived
hoverHeader: !panel.title && !panel.timeFrom && !panel.timeShift, hoverHeader: !panel.title && !panel.timeFrom && !panel.timeShift,
$data: createPanelDataProvider(panel), $data: createPanelDataProvider(panel),
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
}), }),
}); });
} }
export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | undefined { export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | undefined {
// Skip setting query runner for panels without queries
if (!panel.targets?.length) { if (!panel.targets?.length) {
return undefined; return undefined;
} }
// Skip setting query runner for panel plugins with skipDataQuery
if (config.panels[panel.type]?.skipDataQuery) {
return undefined;
}
let dataProvider: SceneDataProvider | undefined = undefined; let dataProvider: SceneDataProvider | undefined = undefined;
if (panel.datasource?.uid === SHARED_DASHBOARD_QUERY) { if (panel.datasource?.uid === SHARED_DASHBOARD_QUERY) {

View File

@@ -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<Props>(({ dashboard }) => {
const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState();
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
if (uid) {
toolbarActions.push(
<DashNavButton
key="button-scenes"
tooltip={'View as dashboard'}
icon="apps"
onClick={() => locationService.push(`/d/${uid}`)}
/>
);
}
toolbarActions.push(<NavToolbarSeparator leftActionsSeparator key="separator" />);
if (viewPanelKey) {
toolbarActions.push(
<Button
onClick={() => locationService.partial({ viewPanel: null })}
tooltip=""
key="back"
variant="primary"
fill="text"
>
Back to dashboard
</Button>
);
return <AppChromeUpdate actions={toolbarActions} />;
}
if (!isEditing) {
// TODO check permissions
toolbarActions.push(
<Button
onClick={dashboard.onEnterEditMode}
tooltip="Enter edit mode"
key="edit"
variant="primary"
icon="pen"
fill="text"
>
Edit
</Button>
);
} else {
// TODO check permissions
toolbarActions.push(
<Button onClick={dashboard.onEnterEditMode} tooltip="Save as copy" fill="text" key="save-as">
Save as
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onDiscard} tooltip="Save changes" fill="text" key="discard" variant="destructive">
Discard
</Button>
);
toolbarActions.push(
<Button onClick={dashboard.onEnterEditMode} tooltip="Save changes" key="save" disabled={!isDirty}>
Save
</Button>
);
}
return <AppChromeUpdate actions={toolbarActions} />;
});
NavToolbarActions.displayName = 'NavToolbarActions';

View File

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

View File

@@ -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<Props>(({ panel, dashboard }) => {
return (
<Drawer
title={`Inspect: ${panel.state.title}`}
scrollableContent
onClose={dashboard.onCloseInspectDrawer}
size="md"
>
Magic content
</Drawer>
);
});
ScenePanelInspector.displayName = 'ScenePanelInspector';

View File

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

View File

@@ -76,3 +76,33 @@ const throwUnhandledRejections = () => {
}; };
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() {}
};