mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
9da3db1ddf
commit
e08700c1b5
24
e2e/dashboards-suite/embedded-dashboard.spec.ts
Normal file
24
e2e/dashboards-suite/embedded-dashboard.spec.ts
Normal file
@ -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) {
|
||||
items.push({
|
||||
text: t('panel.header-menu.view', `View`),
|
||||
iconClassName: 'eye',
|
||||
shortcut: 'v',
|
||||
onClick: () => DashboardInteractions.panelMenuItemClicked('view'),
|
||||
href: getViewPanelUrl(panel),
|
||||
});
|
||||
const exploreMenuItem = await getExploreMenuItem(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.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',
|
||||
});
|
||||
|
||||
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);
|
||||
// For embedded dashboards we only have explore action for now
|
||||
if (isEmbedded) {
|
||||
if (exploreMenuItem) {
|
||||
menu.setState({ items: [exploreMenuItem] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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 (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);
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
text: t('panel.header-menu.view', `View`),
|
||||
iconClassName: 'eye',
|
||||
shortcut: 'v',
|
||||
onClick: () => DashboardInteractions.panelMenuItemClicked('view'),
|
||||
href: getViewPanelUrl(panel),
|
||||
});
|
||||
|
||||
if (dashboard instanceof DashboardScene) {
|
||||
const { extensions } = getPluginLinkExtensions({
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
context: createExtensionContext(panel, dashboard),
|
||||
limitPerPlugin: 3,
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
if (extensions.length > 0 && !dashboard.state.isEditing) {
|
||||
items.push({
|
||||
text: 'Extensions',
|
||||
iconClassName: 'plug',
|
||||
type: 'submenu',
|
||||
subMenu: createExtensionSubMenu(extensions),
|
||||
});
|
||||
}
|
||||
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',
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (exploreMenuItem) {
|
||||
items.push(exploreMenuItem);
|
||||
}
|
||||
|
||||
items.push(getInspectMenuItem(plugin, panel, dashboard));
|
||||
|
||||
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 (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 {
|
||||
|
Loading…
Reference in New Issue
Block a user