mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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"],
|
||||
|
||||
@@ -66,6 +66,7 @@ export function getPanelPlugin(
|
||||
hideFromList: options.hideFromList === true,
|
||||
module: options.module ?? '',
|
||||
baseUrl: '',
|
||||
skipDataQuery: options.skipDataQuery,
|
||||
};
|
||||
return plugin;
|
||||
}
|
||||
|
||||
51
public/app/features/scenes/dashboard/DashboardScene.test.tsx
Normal file
51
public/app/features/scenes/dashboard/DashboardScene.test.tsx
Normal 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;
|
||||
}
|
||||
@@ -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<DashboardSceneState> {
|
||||
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<DashboardSceneState> {
|
||||
// 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
public/app/features/scenes/dashboard/DashboardScenePage.test.tsx
Normal file
156
public/app/features/scenes/dashboard/DashboardScenePage.test.tsx
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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<DashboardScene>) {
|
||||
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) => <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 />);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const inspectPanel = model.findPanel(inspectPanelKey);
|
||||
const viewPanel = model.findPanel(viewPanelKey);
|
||||
const location = useLocation();
|
||||
const pageNav = model.getPageNav(location);
|
||||
|
||||
return (
|
||||
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Custom}>
|
||||
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||
<CustomScrollbar autoHeightMin={'100%'}>
|
||||
<div className={styles.canvasContent}>
|
||||
<AppChromeUpdate actions={toolbarActions} />
|
||||
<NavToolbarActions dashboard={model} />
|
||||
{controls && (
|
||||
<div className={styles.controls}>
|
||||
{controls.map((control) => (
|
||||
@@ -75,11 +31,18 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{viewPanel ? (
|
||||
<div className={styles.viewPanel}>
|
||||
<viewPanel.Component model={viewPanel} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.body}>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
{inspectPanel && <ScenePanelInspector panel={inspectPanel} dashboard={model} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<DashboardLoaderState> {
|
||||
private cache: Record<string, DashboardScene> = {};
|
||||
|
||||
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) {
|
||||
private async loadScene(uid: string): Promise<DashboardScene> {
|
||||
const fromCache = this.cache[uid];
|
||||
if (fromCache) {
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
|
||||
|
||||
if (rsp.dashboard) {
|
||||
// Just to have migrations run
|
||||
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, {
|
||||
autoMigrateOldPanels: true,
|
||||
});
|
||||
|
||||
const dashboard = createDashboardSceneFromDashboardModel(oldModel);
|
||||
return createDashboardSceneFromDashboardModel(oldModel);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
this.cache[rsp.dashboard.uid] = dashboard;
|
||||
this.setState({ dashboard, isLoading: false });
|
||||
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) {
|
||||
|
||||
84
public/app/features/scenes/dashboard/NavToolbarActions.tsx
Normal file
84
public/app/features/scenes/dashboard/NavToolbarActions.tsx
Normal 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';
|
||||
37
public/app/features/scenes/dashboard/PanelMenuBehavior.tsx
Normal file
37
public/app/features/scenes/dashboard/PanelMenuBehavior.tsx
Normal 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 });
|
||||
}
|
||||
26
public/app/features/scenes/dashboard/ScenePanelInspector.tsx
Normal file
26
public/app/features/scenes/dashboard/ScenePanelInspector.tsx
Normal 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';
|
||||
40
public/app/features/scenes/dashboard/test-utils.ts
Normal file
40
public/app/features/scenes/dashboard/test-utils.ts
Normal 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() {}
|
||||
};
|
||||
}
|
||||
@@ -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() {}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user