Dashboard: New EmbeddedDashboard runtime component (#78916)

* Embedding dashboards exploratino

* Update

* Update

* Added e2e test

* Update

* initial state, and onStateChange, only explore panel menu action and other fixes and tests

* fix e2e spec

* Fix url

* fixing test
pull/81332/head
Torkel Ödegaard 1 year ago committed by GitHub
parent 9da3db1ddf
commit e08700c1b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      e2e/dashboards-suite/embedded-dashboard.spec.ts
  2. 1
      packages/grafana-runtime/package.json
  3. 32
      packages/grafana-runtime/src/components/EmbeddedDashboard.tsx
  4. 1
      packages/grafana-runtime/src/index.ts
  5. 3
      public/app/app.ts
  6. 130
      public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx
  7. 12
      public/app/features/dashboard-scene/embedding/EmbeddedDashboardLazy.tsx
  8. 22
      public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx
  9. 4
      public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
  10. 34
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
  11. 33
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
  12. 21
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
  13. 251
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx
  14. 4
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  15. 2
      public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts
  16. 2
      public/app/features/dashboard/containers/DashboardPageProxy.tsx
  17. 10
      public/app/routes/routes.tsx
  18. 1
      public/app/types/dashboard.ts
  19. 1499
      yarn.lock

@ -0,0 +1,24 @@
import { selectors } from '@grafana/e2e-selectors';
import { e2e } from '../utils';
import { fromBaseUrl } from '../utils/support/url';
describe('Embedded dashboard', function () {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('open test page', function () {
cy.visit(fromBaseUrl('/dashboards/embedding-test'));
// Verify pie charts are rendered
cy.get(
`[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]`
).should('have.length', 5);
// Verify no url sync
e2e.components.TimePicker.openButton().click();
cy.get('label:contains("Last 1 hour")').click();
cy.url().should('eq', fromBaseUrl('/dashboards/embedding-test'));
});
});

@ -40,6 +40,7 @@
"@grafana/data": "10.4.0-pre",
"@grafana/e2e-selectors": "10.4.0-pre",
"@grafana/faro-web-sdk": "^1.3.6",
"@grafana/schema": "10.4.0-pre",
"@grafana/ui": "10.4.0-pre",
"history": "4.10.1",
"lodash": "4.17.21",

@ -0,0 +1,32 @@
import React from 'react';
export interface EmbeddedDashboardProps {
uid?: string;
/**
* Use this property to override initial time and variable state.
* Example: ?from=now-5m&to=now&var-varname=value1
*/
initialState?: string;
/**
* Is called when ever the internal embedded dashboards url state changes.
* Can be used to sync the internal url state (Which is not synced to URL) with the external context, or to
* preserve some of the state when moving to other embedded dashboards.
*/
onStateChange?: (state: string) => void;
}
/**
* Returns a React component that renders an embedded dashboard.
* @alpha
*/
export let EmbeddedDashboard: React.ComponentType<EmbeddedDashboardProps> = () => {
throw new Error('EmbeddedDashboard requires runtime initialization');
};
/**
*
* @internal
*/
export function setEmbeddedDashboard(component: React.ComponentType<EmbeddedDashboardProps>) {
EmbeddedDashboard = component;
}

@ -55,3 +55,4 @@ export {
createDataSourcePluginEventProperties,
} from './analytics/plugins/eventProperties';
export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter';
export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard } from './components/EmbeddedDashboard';

@ -33,6 +33,7 @@ import {
setRunRequest,
setPluginImportUtils,
setPluginExtensionGetter,
setEmbeddedDashboard,
setAppEvents,
type GetPluginExtensions,
} from '@grafana/runtime';
@ -72,6 +73,7 @@ import { startMeasure, stopMeasure } from './core/utils/metrics';
import { initDevFeatures } from './dev';
import { initAuthConfig } from './features/auth-config';
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy';
import { initGrafanaLive } from './features/live';
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { PanelRenderer } from './features/panel/components/PanelRenderer';
@ -134,6 +136,7 @@ export class GrafanaApp {
setPluginPage(PluginPage);
setPanelDataErrorView(PanelDataErrorView);
setLocationSrv(locationService);
setEmbeddedDashboard(EmbeddedDashboardLazy);
setTimeZoneResolver(() => config.bootData.user.timezone);
initGrafanaLive();

@ -0,0 +1,130 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { EmbeddedDashboardProps } from '@grafana/runtime';
import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes';
import { Spinner, Alert, useStyles2 } from '@grafana/ui';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { DashboardScene } from '../scene/DashboardScene';
export function EmbeddedDashboard(props: EmbeddedDashboardProps) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard, loadError } = stateManager.useState();
useEffect(() => {
stateManager.loadDashboard({ uid: props.uid!, isEmbedded: true });
return () => {
stateManager.clearState();
};
}, [stateManager, props.uid]);
if (loadError) {
return (
<Alert severity="error" title="Failed to load dashboard">
{loadError}
</Alert>
);
}
if (!dashboard) {
return <Spinner />;
}
return <EmbeddedDashboardRenderer model={dashboard} {...props} />;
}
interface RendererProps extends EmbeddedDashboardProps {
model: DashboardScene;
}
function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) {
const [isActive, setIsActive] = useState(false);
const { controls, body } = model.useState();
const styles = useStyles2(getStyles);
useEffect(() => {
setIsActive(true);
if (initialState) {
const searchParms = new URLSearchParams(initialState);
sceneUtils.syncStateFromSearchParams(model, searchParms);
}
return model.activate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model]);
useSubscribeToEmbeddedUrlState(onStateChange, model);
if (!isActive) {
return null;
}
return (
<div className={styles.canvas}>
{controls && (
<div className={styles.controls}>
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
</div>
)}
<div className={styles.body}>
<body.Component model={body} />
</div>
</div>
);
}
function useSubscribeToEmbeddedUrlState(onStateChange: ((state: string) => void) | undefined, model: DashboardScene) {
useEffect(() => {
if (!onStateChange) {
return;
}
let lastState = '';
const sub = model.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
if (evt.payload.changedObject.urlSync) {
const state = sceneUtils.getUrlState(model);
const stateAsString = urlUtil.renderUrl('', state);
if (lastState !== stateAsString) {
lastState = stateAsString;
onStateChange(stateAsString);
}
}
});
return () => sub.unsubscribe();
}, [model, onStateChange]);
}
function getStyles(theme: GrafanaTheme2) {
return {
canvas: css({
label: 'canvas-content',
display: 'flex',
flexDirection: 'column',
flexBasis: '100%',
flexGrow: 1,
}),
body: css({
label: 'body',
flexGrow: 1,
display: 'flex',
gap: '8px',
marginBottom: theme.spacing(2),
}),
controls: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: theme.spacing(1),
top: 0,
zIndex: theme.zIndex.navbarFixed,
padding: theme.spacing(0, 0, 2, 0),
}),
};
}

@ -0,0 +1,12 @@
import React from 'react';
import { EmbeddedDashboardProps } from '@grafana/runtime';
export function EmbeddedDashboardLazy(props: EmbeddedDashboardProps) {
return <Component {...props} />;
}
const Component = React.lazy(async () => {
const { EmbeddedDashboard } = await import(/* webpackChunkName: "EmbeddedDashboard" */ './EmbeddedDashboard');
return { default: EmbeddedDashboard };
});

@ -0,0 +1,22 @@
import React, { useState } from 'react';
import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { EmbeddedDashboard } from './EmbeddedDashboard';
export function EmbeddedDashboardTestPage() {
const [state, setState] = useState('?from=now-5m&to=now');
return (
<Page
navId="dashboards/browse"
pageNav={{ text: 'Embedding dashboard', subTitle: 'Showing dashboard: Panel Tests - Pie chart' }}
>
<Box paddingY={2}>Internal url state: {state}</Box>
<EmbeddedDashboard uid="lVE-2YFMz" initialState={state} onStateChange={setState} />
</Page>
);
}
export default EmbeddedDashboardTestPage;

@ -18,9 +18,9 @@ export function DashboardScenePage({ match, route }: Props) {
useEffect(() => {
if (route.routeName === DashboardRoutes.Home) {
stateManager.loadDashboard(route.routeName);
stateManager.loadDashboard({ uid: route.routeName });
} else {
stateManager.loadDashboard(match.params.uid!);
stateManager.loadDashboard({ uid: match.params.uid! });
}
return () => {

@ -14,12 +14,12 @@ describe('DashboardScenePageStateManager', () => {
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
// should use cache second time
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loadDashboardMock.mock.calls.length).toBe(1);
});
@ -27,7 +27,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: undefined, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.isLoading).toBe(false);
@ -38,7 +38,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
expect(loader.state.loadError).toBe(undefined);
@ -49,7 +49,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
expect(loader.state.isLoading).toBe(false);
@ -61,7 +61,7 @@ describe('DashboardScenePageStateManager', () => {
locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
@ -71,12 +71,24 @@ describe('DashboardScenePageStateManager', () => {
// try loading again (and hitting cache)
locationService.partial({ from: 'now-10m', to: 'now' });
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
const dash2 = loader.state.dashboard;
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
});
it('should not initialize url sync for embedded dashboards', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', isEmbedded: true });
const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-6h');
});
describe('caching', () => {
it('should cache the dashboard DTO', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
@ -85,7 +97,7 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.getFromCache('fake-dash')).toBeNull();
await loader.loadDashboard('fake-dash');
await loader.loadDashboard({ uid: 'fake-dash' });
expect(loader.getFromCache('fake-dash')).toBeDefined();
});
@ -98,15 +110,15 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.getFromCache('fake-dash')).toBeNull();
await loader.fetchDashboard('fake-dash');
await loader.fetchDashboard({ uid: 'fake-dash' });
expect(loadDashSpy).toHaveBeenCalledTimes(1);
advanceBy(DASHBOARD_CACHE_TTL / 2);
await loader.fetchDashboard('fake-dash');
await loader.fetchDashboard({ uid: 'fake-dash' });
expect(loadDashSpy).toHaveBeenCalledTimes(1);
advanceBy(DASHBOARD_CACHE_TTL / 2 + 1);
await loader.fetchDashboard('fake-dash');
await loader.fetchDashboard({ uid: 'fake-dash' });
expect(loadDashSpy).toHaveBeenCalledTimes(2);
});
});

@ -26,6 +26,12 @@ interface DashboardCacheEntry {
dashboard: DashboardDTO;
ts: number;
}
export interface LoadDashboardOptions {
uid: string;
isEmbedded?: boolean;
}
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
private cache: Record<string, DashboardScene> = {};
// This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span.
@ -33,7 +39,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// To eventualy replace the fetchDashboard function from Dashboard redux state management.
// For now it's a simplistic version to support Home and Normal dashboard routes.
public async fetchDashboard(uid: string) {
public async fetchDashboard({ uid, isEmbedded }: LoadDashboardOptions) {
const cachedDashboard = this.getFromCache(uid);
if (cachedDashboard) {
@ -63,7 +69,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
}
if (rsp) {
if (rsp.meta.url) {
if (rsp.meta.url && !isEmbedded) {
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
const currentPath = locationService.getLocation().pathname;
if (dashboardUrl !== currentPath) {
@ -94,10 +100,13 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
return rsp;
}
public async loadDashboard(uid: string) {
public async loadDashboard(options: LoadDashboardOptions) {
try {
const dashboard = await this.loadScene(uid);
dashboard.startUrlSync();
const dashboard = await this.loadScene(options);
if (!options.isEmbedded) {
dashboard.startUrlSync();
}
this.setState({ dashboard: dashboard, isLoading: false });
} catch (err) {
@ -105,20 +114,26 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
}
}
private async loadScene(uid: string): Promise<DashboardScene> {
const fromCache = this.cache[uid];
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene> {
const fromCache = this.cache[options.uid];
if (fromCache) {
// Need to update this in case we cached an embedded but now opening it standard mode
fromCache.state.meta.isEmbedded = options.isEmbedded;
return fromCache;
}
this.setState({ isLoading: true });
const rsp = await this.fetchDashboard(uid);
const rsp = await this.fetchDashboard(options);
if (rsp?.dashboard) {
if (options.isEmbedded) {
rsp.meta.isEmbedded = true;
}
const scene = transformSaveModelToScene(rsp);
this.cache[uid] = scene;
this.cache[options.uid] = scene;
return scene;
}

@ -468,10 +468,28 @@ describe('panelMenuBehavior', () => {
])
);
});
it('should only contain explore when embedded', async () => {
const { menu, panel } = await buildTestScene({ isEmbedded: true });
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(1);
expect(menu.state.items?.[0].text).toBe('Explore');
});
});
});
interface SceneOptions {}
interface SceneOptions {
isEmbedded?: boolean;
}
async function buildTestScene(options: SceneOptions) {
const menu = new VizPanelMenu({
@ -503,6 +521,7 @@ async function buildTestScene(options: SceneOptions) {
}),
meta: {
canEdit: true,
isEmbedded: options.isEmbedded ?? false,
},
body: new SceneGridLayout({
children: [

@ -1,6 +1,7 @@
import {
InterpolateFunction,
PanelMenuItem,
PanelPlugin,
PluginExtensionPanelContext,
PluginExtensionPoints,
getTimeZone,
@ -18,7 +19,7 @@ import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegratio
import { ShareModal } from '../sharing/ShareModal';
import { DashboardInteractions } from '../utils/interactions';
import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { LibraryVizPanel } from './LibraryVizPanel';
@ -36,139 +37,92 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
const items: PanelMenuItem[] = [];
const moreSubMenu: PanelMenuItem[] = [];
const inspectSubMenu: PanelMenuItem[] = [];
const panelId = getPanelIdForVizPanel(panel);
const dashboard = panel.getRoot();
const dashboard = getDashboardSceneFor(panel);
const { isEmbedded } = dashboard.state.meta;
if (dashboard instanceof DashboardScene) {
const exploreMenuItem = await getExploreMenuItem(panel);
// For embedded dashboards we only have explore action for now
if (isEmbedded) {
if (exploreMenuItem) {
menu.setState({ items: [exploreMenuItem] });
}
return;
}
items.push({
text: t('panel.header-menu.view', `View`),
iconClassName: 'eye',
shortcut: 'v',
onClick: () => DashboardInteractions.panelMenuItemClicked('view'),
href: getViewPanelUrl(panel),
});
if (dashboard.canEditDashboard()) {
// We could check isEditing here but I kind of think this should always be in the menu,
// and going into panel edit should make the dashboard go into edit mode is it's not already
items.push({
text: t('panel.header-menu.view', `View`),
text: t('panel.header-menu.edit', `Edit`),
iconClassName: 'eye',
shortcut: 'v',
onClick: () => DashboardInteractions.panelMenuItemClicked('view'),
href: getViewPanelUrl(panel),
shortcut: 'e',
onClick: () => DashboardInteractions.panelMenuItemClicked('edit'),
href: getEditPanelUrl(panelId),
});
}
if (dashboard.canEditDashboard()) {
// We could check isEditing here but I kind of think this should always be in the menu,
// and going into panel edit should make the dashboard go into edit mode is it's not already
items.push({
text: t('panel.header-menu.edit', `Edit`),
iconClassName: 'eye',
shortcut: 'e',
onClick: () => DashboardInteractions.panelMenuItemClicked('edit'),
href: getEditPanelUrl(panelId),
});
}
items.push({
text: t('panel.header-menu.share', `Share`),
iconClassName: 'share-alt',
onClick: () => {
DashboardInteractions.panelMenuItemClicked('share');
dashboard.showModal(new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef() }));
},
shortcut: 'p s',
});
items.push({
text: t('panel.header-menu.share', `Share`),
if (panel.parent instanceof LibraryVizPanel) {
// TODO: Implement lib panel unlinking
} else {
moreSubMenu.push({
text: t('panel.header-menu.create-library-panel', `Create library panel`),
iconClassName: 'share-alt',
onClick: () => {
DashboardInteractions.panelMenuItemClicked('share');
dashboard.showModal(new ShareModal({ panelRef: panel.getRef(), dashboardRef: dashboard.getRef() }));
DashboardInteractions.panelMenuItemClicked('createLibraryPanel');
dashboard.showModal(
new ShareModal({
panelRef: panel.getRef(),
dashboardRef: dashboard.getRef(),
activeTab: 'Library panel',
})
);
},
shortcut: 'p s',
});
if (panel.parent instanceof LibraryVizPanel) {
// TODO: Implement lib panel unlinking
} else {
moreSubMenu.push({
text: t('panel.header-menu.create-library-panel', `Create library panel`),
iconClassName: 'share-alt',
onClick: () => {
DashboardInteractions.panelMenuItemClicked('createLibraryPanel');
dashboard.showModal(
new ShareModal({
panelRef: panel.getRef(),
dashboardRef: dashboard.getRef(),
activeTab: 'Library panel',
})
);
},
});
}
if (config.featureToggles.datatrails) {
addDataTrailPanelAction(dashboard, panel, items);
}
}
const exploreUrl = await tryGetExploreUrlForPanel(panel);
if (exploreUrl) {
items.push({
text: t('panel.header-menu.explore', `Explore`),
iconClassName: 'compass',
shortcut: 'p x',
onClick: () => DashboardInteractions.panelMenuItemClicked('explore'),
href: exploreUrl,
});
if (config.featureToggles.datatrails) {
addDataTrailPanelAction(dashboard, panel, items);
}
if (plugin && !plugin.meta.skipDataQuery) {
inspectSubMenu.push({
text: t('panel.header-menu.inspect-data', `Data`),
href: getInspectUrl(panel, InspectTab.Data),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Data);
},
});
if (dashboard instanceof DashboardScene && dashboard.state.meta.canEdit) {
inspectSubMenu.push({
text: t('panel.header-menu.query', `Query`),
href: getInspectUrl(panel, InspectTab.Query),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Query });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Query);
},
});
}
if (exploreMenuItem) {
items.push(exploreMenuItem);
}
inspectSubMenu.push({
text: t('panel.header-menu.inspect-json', `Panel JSON`),
href: getInspectUrl(panel, InspectTab.JSON),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.JSON });
DashboardInteractions.panelMenuInspectClicked(InspectTab.JSON);
},
});
items.push(getInspectMenuItem(plugin, panel, dashboard));
items.push({
text: t('panel.header-menu.inspect', `Inspect`),
iconClassName: 'info-circle',
shortcut: 'i',
href: getInspectUrl(panel),
onClick: (e) => {
if (!e.isDefaultPrevented()) {
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Data);
}
},
subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined,
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
});
if (dashboard instanceof DashboardScene) {
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
if (extensions.length > 0 && !dashboard.state.isEditing) {
items.push({
text: 'Extensions',
iconClassName: 'plug',
type: 'submenu',
subMenu: createExtensionSubMenu(extensions),
});
if (extensions.length > 0 && !dashboard.state.isEditing) {
items.push({
text: 'Extensions',
iconClassName: 'plug',
type: 'submenu',
subMenu: createExtensionSubMenu(extensions),
});
}
}
if (moreSubMenu.length) {
@ -189,6 +143,77 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
asyncFunc();
}
async function getExploreMenuItem(panel: VizPanel): Promise<PanelMenuItem | undefined> {
const exploreUrl = await tryGetExploreUrlForPanel(panel);
if (!exploreUrl) {
return undefined;
}
return {
text: t('panel.header-menu.explore', `Explore`),
iconClassName: 'compass',
shortcut: 'p x',
onClick: () => DashboardInteractions.panelMenuItemClicked('explore'),
href: exploreUrl,
};
}
function getInspectMenuItem(
plugin: PanelPlugin | undefined,
panel: VizPanel,
dashboard: DashboardScene
): PanelMenuItem {
const inspectSubMenu: PanelMenuItem[] = [];
if (plugin && !plugin.meta.skipDataQuery) {
inspectSubMenu.push({
text: t('panel.header-menu.inspect-data', `Data`),
href: getInspectUrl(panel, InspectTab.Data),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Data);
},
});
if (dashboard instanceof DashboardScene && dashboard.state.meta.canEdit) {
inspectSubMenu.push({
text: t('panel.header-menu.query', `Query`),
href: getInspectUrl(panel, InspectTab.Query),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Query });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Query);
},
});
}
}
inspectSubMenu.push({
text: t('panel.header-menu.inspect-json', `Panel JSON`),
href: getInspectUrl(panel, InspectTab.JSON),
onClick: (e) => {
e.preventDefault();
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.JSON });
DashboardInteractions.panelMenuInspectClicked(InspectTab.JSON);
},
});
return {
text: t('panel.header-menu.inspect', `Inspect`),
iconClassName: 'info-circle',
shortcut: 'i',
href: getInspectUrl(panel),
onClick: (e) => {
if (!e.isDefaultPrevented()) {
locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data });
DashboardInteractions.panelMenuInspectClicked(InspectTab.Data);
}
},
subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined,
};
}
/**
* Behavior is called when VizPanelLinksMenu is activated (when it's opened).
*/

@ -62,6 +62,10 @@ export interface DashboardLoaderState {
loadError?: string;
}
export interface SaveModelToSceneOptions {
isEmbedded?: boolean;
}
export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
// Just to have migrations run
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta, {

@ -82,7 +82,7 @@ export class SupportSnapshotService extends StateManagerBase<SupportSnapshotStat
if (!panel.isAngularPlugin()) {
try {
const oldModel = new DashboardModel(snapshot);
const oldModel = new DashboardModel(snapshot, { isEmbedded: true });
const dash = createDashboardSceneFromDashboardModel(oldModel);
scene = dash.state.body; // skip the wrappers
} catch (ex) {

@ -38,7 +38,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return null;
}
return stateManager.fetchDashboard(dashToFetch);
return stateManager.fetchDashboard({ uid: dashToFetch });
}, [props.match.params.uid, props.route.routeName]);
if (!config.featureToggles.dashboardSceneForViewers) {

@ -70,6 +70,16 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy')
),
},
{
// We currently have no core usage of the embedded dashboard so is to have a page for e2e to test
path: '/dashboards/embedding-test',
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "DashboardPage"*/ 'app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage'
)
),
},
{
path: '/d-solo/:uid/:slug',
pageClass: 'dashboard-solo',

@ -50,6 +50,7 @@ export interface DashboardMeta {
publicDashboardUid?: string;
publicDashboardEnabled?: boolean;
dashboardNotFound?: boolean;
isEmbedded?: boolean;
}
export interface AnnotationActions {

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save