DashboardScene: Initial work to support "new" dashboard route and creation logic (#81549)

* DashboardScene: Initial work to get new route to work

* Update

* remove caching of new dashboard

* remove old new dashboard func

* Update

* Update public/app/features/dashboard-scene/scene/DashboardScene.tsx

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Fixing test

* dam messy tests

---------

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>
This commit is contained in:
Torkel Ödegaard 2024-01-31 11:33:46 +01:00 committed by GitHub
parent 39057552dc
commit 3b2352f066
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 218 additions and 143 deletions

View File

@ -2379,6 +2379,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [ "public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
], ],
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [ "public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
@ -2753,6 +2756,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"]
], ],
"public/app/features/dashboard/containers/DashboardPageProxy.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx:5381": [ "public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
@ -2909,9 +2915,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"] [0, 0, 0, "Unexpected any. Specify a different type.", "6"]
], ],
"public/app/features/dashboard/state/initDashboard.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard/utils/getPanelMenu.test.ts:5381": [ "public/app/features/dashboard/utils/getPanelMenu.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],

View File

@ -5,6 +5,7 @@ import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { EmbeddedDashboardProps } from '@grafana/runtime'; import { EmbeddedDashboardProps } from '@grafana/runtime';
import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes'; import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes';
import { Spinner, Alert, useStyles2 } from '@grafana/ui'; import { Spinner, Alert, useStyles2 } from '@grafana/ui';
import { DashboardRoutes } from 'app/types';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
@ -14,7 +15,7 @@ export function EmbeddedDashboard(props: EmbeddedDashboardProps) {
const { dashboard, loadError } = stateManager.useState(); const { dashboard, loadError } = stateManager.useState();
useEffect(() => { useEffect(() => {
stateManager.loadDashboard({ uid: props.uid!, isEmbedded: true }); stateManager.loadDashboard({ uid: props.uid!, route: DashboardRoutes.Embedded });
return () => { return () => {
stateManager.clearState(); stateManager.clearState();
}; };

View File

@ -5,28 +5,28 @@ import { PageLayoutType } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
import { DashboardRoutes } from 'app/types'; import { DashboardRoutes } from 'app/types';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams> {} export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> {}
export function DashboardScenePage({ match, route }: Props) { export function DashboardScenePage({ match, route, queryParams }: Props) {
const stateManager = getDashboardScenePageStateManager(); const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState(); const { dashboard, isLoading, loadError } = stateManager.useState();
useEffect(() => { useEffect(() => {
if (route.routeName === DashboardRoutes.Home) { stateManager.loadDashboard({
stateManager.loadDashboard({ uid: route.routeName }); uid: match.params.uid ?? '',
} else { route: route.routeName as DashboardRoutes,
stateManager.loadDashboard({ uid: match.params.uid! }); urlFolderUid: queryParams.folderUid,
} });
return () => { return () => {
stateManager.clearState(); stateManager.clearState();
}; };
}, [stateManager, match.params.uid, route.routeName]); }, [stateManager, match.params.uid, route.routeName, queryParams.folderUid]);
if (!dashboard) { if (!dashboard) {
return ( return (

View File

@ -2,6 +2,7 @@ import { advanceBy } from 'jest-date-mock';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { getUrlSyncManager } from '@grafana/scenes'; import { getUrlSyncManager } from '@grafana/scenes';
import { DashboardRoutes } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { setupLoadDashboardMock } from '../utils/test-utils'; import { setupLoadDashboardMock } from '../utils/test-utils';
@ -14,12 +15,12 @@ describe('DashboardScenePageStateManager', () => {
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} }); const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash');
// should use cache second time // should use cache second time
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashboardMock.mock.calls.length).toBe(1); expect(loadDashboardMock.mock.calls.length).toBe(1);
}); });
@ -27,7 +28,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: undefined, meta: {} }); setupLoadDashboardMock({ dashboard: undefined, meta: {} });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.isLoading).toBe(false); expect(loader.state.isLoading).toBe(false);
@ -38,7 +39,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loader.state.dashboard?.state.uid).toBe('fake-dash'); expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
expect(loader.state.loadError).toBe(undefined); expect(loader.state.loadError).toBe(undefined);
@ -49,7 +50,7 @@ describe('DashboardScenePageStateManager', () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene); expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
expect(loader.state.isLoading).toBe(false); expect(loader.state.isLoading).toBe(false);
@ -61,7 +62,7 @@ describe('DashboardScenePageStateManager', () => {
locationService.partial({ from: 'now-5m', to: 'now' }); locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
const dash = loader.state.dashboard; const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m'); expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
@ -71,7 +72,7 @@ describe('DashboardScenePageStateManager', () => {
// try loading again (and hitting cache) // try loading again (and hitting cache)
locationService.partial({ from: 'now-10m', to: 'now' }); locationService.partial({ from: 'now-10m', to: 'now' });
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
const dash2 = loader.state.dashboard; const dash2 = loader.state.dashboard;
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
@ -83,12 +84,32 @@ describe('DashboardScenePageStateManager', () => {
locationService.partial({ from: 'now-5m', to: 'now' }); locationService.partial({ from: 'now-5m', to: 'now' });
const loader = new DashboardScenePageStateManager({}); const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', isEmbedded: true }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Embedded });
const dash = loader.state.dashboard; const dash = loader.state.dashboard;
expect(dash!.state.$timeRange?.state.from).toEqual('now-6h'); expect(dash!.state.$timeRange?.state.from).toEqual('now-6h');
}); });
describe('New dashboards', () => {
it('Should have new empty model with meta.isNew and should not be cached', async () => {
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: '', route: DashboardRoutes.New });
const dashboard = loader.state.dashboard!;
expect(dashboard.state.meta.isNew).toBe(true);
expect(dashboard.state.isEditing).toBe(true);
expect(dashboard.state.isDirty).toBe(true);
dashboard.setState({ title: 'Changed' });
await loader.loadDashboard({ uid: '', route: DashboardRoutes.New });
const dashboard2 = loader.state.dashboard!;
expect(dashboard2.state.title).toBe('New dashboard');
});
});
describe('caching', () => { describe('caching', () => {
it('should cache the dashboard DTO', async () => { it('should cache the dashboard DTO', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
@ -97,7 +118,7 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.getFromCache('fake-dash')).toBeNull(); expect(loader.getFromCache('fake-dash')).toBeNull();
await loader.loadDashboard({ uid: 'fake-dash' }); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loader.getFromCache('fake-dash')).toBeDefined(); expect(loader.getFromCache('fake-dash')).toBeDefined();
}); });
@ -110,15 +131,15 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.getFromCache('fake-dash')).toBeNull(); expect(loader.getFromCache('fake-dash')).toBeNull();
await loader.fetchDashboard({ uid: 'fake-dash' }); await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashSpy).toHaveBeenCalledTimes(1); expect(loadDashSpy).toHaveBeenCalledTimes(1);
advanceBy(DASHBOARD_CACHE_TTL / 2); advanceBy(DASHBOARD_CACHE_TTL / 2);
await loader.fetchDashboard({ uid: 'fake-dash' }); await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashSpy).toHaveBeenCalledTimes(1); expect(loadDashSpy).toHaveBeenCalledTimes(1);
advanceBy(DASHBOARD_CACHE_TTL / 2 + 1); advanceBy(DASHBOARD_CACHE_TTL / 2 + 1);
await loader.fetchDashboard({ uid: 'fake-dash' }); await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loadDashSpy).toHaveBeenCalledTimes(2); expect(loadDashSpy).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@ -11,6 +11,7 @@ import { DashboardDTO, DashboardRoutes } from 'app/types';
import { PanelEditor } from '../panel-edit/PanelEditor'; import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
export interface DashboardScenePageState { export interface DashboardScenePageState {
@ -22,6 +23,9 @@ export interface DashboardScenePageState {
export const DASHBOARD_CACHE_TTL = 2000; export const DASHBOARD_CACHE_TTL = 2000;
/** Only used by cache in loading home in DashboardPageProxy and initDashboard (Old arch), can remove this after old dashboard arch is gone */
export const HOME_DASHBOARD_CACHE_KEY = '__grafana_home_uid__';
interface DashboardCacheEntry { interface DashboardCacheEntry {
dashboard: DashboardDTO; dashboard: DashboardDTO;
ts: number; ts: number;
@ -29,7 +33,8 @@ interface DashboardCacheEntry {
export interface LoadDashboardOptions { export interface LoadDashboardOptions {
uid: string; uid: string;
isEmbedded?: boolean; route: DashboardRoutes;
urlFolderUid?: string;
} }
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> { export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
@ -39,8 +44,9 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// To eventualy replace the fetchDashboard function from Dashboard redux state management. // 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. // For now it's a simplistic version to support Home and Normal dashboard routes.
public async fetchDashboard({ uid, isEmbedded }: LoadDashboardOptions) { public async fetchDashboard({ uid, route, urlFolderUid }: LoadDashboardOptions) {
const cachedDashboard = this.getFromCache(uid); const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
const cachedDashboard = this.getFromCache(cacheKey);
if (cachedDashboard) { if (cachedDashboard) {
return cachedDashboard; return cachedDashboard;
@ -49,27 +55,37 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
let rsp: DashboardDTO | undefined; let rsp: DashboardDTO | undefined;
try { try {
if (uid === DashboardRoutes.Home) { switch (route) {
rsp = await getBackendSrv().get('/api/dashboards/home'); case DashboardRoutes.New:
rsp = buildNewDashboardSaveModel(urlFolderUid);
break;
case DashboardRoutes.Home:
rsp = await getBackendSrv().get('/api/dashboards/home');
// If user specified a custom home dashboard redirect to that // If user specified a custom home dashboard redirect to that
if (rsp?.redirectUri) { if (rsp?.redirectUri) {
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
locationService.replace(newUrl); locationService.replace(newUrl);
return null; return null;
} }
if (rsp?.meta) { if (rsp?.meta) {
rsp.meta.canSave = false; rsp.meta.canSave = false;
rsp.meta.canShare = false; rsp.meta.canShare = false;
rsp.meta.canStar = false; rsp.meta.canStar = false;
} }
} else {
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); break;
default:
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid);
if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
}
} }
if (rsp) { if (rsp) {
if (rsp.meta.url && !isEmbedded) { if (rsp.meta.url && route !== DashboardRoutes.Embedded) {
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url); const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
const currentPath = locationService.getLocation().pathname; const currentPath = locationService.getLocation().pathname;
if (dashboardUrl !== currentPath) { if (dashboardUrl !== currentPath) {
@ -85,7 +101,12 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// Populate nav model in global store according to the folder // Populate nav model in global store according to the folder
await this.initNavModel(rsp); await this.initNavModel(rsp);
this.dashboardCache.set(uid, { dashboard: rsp, ts: Date.now() }); // Do not cache new dashboards
if (uid) {
this.dashboardCache.set(uid, { dashboard: rsp, ts: Date.now() });
} else if (route === DashboardRoutes.Home) {
this.dashboardCache.set(HOME_DASHBOARD_CACHE_KEY, { dashboard: rsp, ts: Date.now() });
}
} }
} catch (e) { } catch (e) {
// Ignore cancelled errors // Ignore cancelled errors
@ -103,10 +124,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
public async loadDashboard(options: LoadDashboardOptions) { public async loadDashboard(options: LoadDashboardOptions) {
try { try {
const dashboard = await this.loadScene(options); const dashboard = await this.loadScene(options);
dashboard.startUrlSync();
if (!options.isEmbedded) {
dashboard.startUrlSync();
}
this.setState({ dashboard: dashboard, isLoading: false }); this.setState({ dashboard: dashboard, isLoading: false });
} catch (err) { } catch (err) {
@ -117,8 +135,6 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene> { private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene> {
const fromCache = this.cache[options.uid]; const fromCache = this.cache[options.uid];
if (fromCache) { 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; return fromCache;
} }
@ -127,13 +143,12 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
const rsp = await this.fetchDashboard(options); const rsp = await this.fetchDashboard(options);
if (rsp?.dashboard) { if (rsp?.dashboard) {
if (options.isEmbedded) {
rsp.meta.isEmbedded = true;
}
const scene = transformSaveModelToScene(rsp); const scene = transformSaveModelToScene(rsp);
this.cache[options.uid] = scene; if (options.uid) {
this.cache[options.uid] = scene;
}
return scene; return scene;
} }

View File

@ -153,7 +153,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
} }
public startUrlSync() { public startUrlSync() {
getUrlSyncManager().initSync(this); if (!this.state.meta.isEmbedded) {
getUrlSyncManager().initSync(this);
}
} }
public stopUrlSync() { public stopUrlSync() {
@ -206,6 +208,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
} }
public onDiscard = () => { public onDiscard = () => {
if (!this.canDiscard()) {
console.error('Trying to discard back to a state that does not exist, initialState undefined');
return;
}
// No need to listen to changes anymore // No need to listen to changes anymore
this.stopTrackingChanges(); this.stopTrackingChanges();
// Stop url sync before updating url // Stop url sync before updating url
@ -233,6 +240,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.propagateEditModeChange(); this.propagateEditModeChange();
}; };
public canDiscard() {
return this._initialState !== undefined;
}
public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => { public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); const versionRsp = await historySrv.restoreDashboard(version.uid, version.version);

View File

@ -128,33 +128,37 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
} }
} else { } else {
if (dashboard.canEditDashboard()) { if (dashboard.canEditDashboard()) {
toolbarActions.push( if (!dashboard.state.meta.isNew) {
<Button toolbarActions.push(
onClick={() => { <Button
dashboard.openSaveDrawer({ saveAsCopy: true }); onClick={() => {
}} dashboard.openSaveDrawer({ saveAsCopy: true });
size="sm" }}
tooltip="Save as copy" size="sm"
fill="text" tooltip="Save as copy"
key="save-as" fill="text"
> key="save-as"
Save as >
</Button> Save as
); </Button>
toolbarActions.push( );
<Button }
onClick={() => { if (dashboard.canDiscard()) {
dashboard.onDiscard(); toolbarActions.push(
}} <Button
tooltip="Discard changes" onClick={() => {
fill="text" dashboard.onDiscard();
size="sm" }}
key="discard" tooltip="Discard changes"
variant="destructive" fill="text"
> size="sm"
Discard key="discard"
</Button> variant="destructive"
); >
Discard
</Button>
);
}
toolbarActions.push( toolbarActions.push(
<Button <Button
onClick={() => { onClick={() => {

View File

@ -0,0 +1,26 @@
import { defaultDashboard } from '@grafana/schema';
import { DashboardDTO } from 'app/types';
export function buildNewDashboardSaveModel(urlFolderUid?: string): DashboardDTO {
const data: DashboardDTO = {
meta: {
canStar: false,
canShare: false,
canDelete: false,
isNew: true,
folderUid: '',
},
dashboard: {
...defaultDashboard,
uid: '',
title: 'New dashboard',
panels: [],
},
};
if (urlFolderUid) {
data.meta.folderUid = urlFolderUid;
}
return data;
}

View File

@ -48,6 +48,7 @@ import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils'; import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils'; import { getQueryRunnerFor } from '../utils/utils';
import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel';
import dashboard_to_load1 from './testfiles/dashboard_to_load1.json'; import dashboard_to_load1 from './testfiles/dashboard_to_load1.json';
import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json'; import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json';
import { import {
@ -167,6 +168,16 @@ describe('transformSaveModelToScene', () => {
}); });
}); });
describe('When creating a new dashboard', () => {
it('should initialize the DashboardScene in edit mode and dirty', () => {
const rsp = buildNewDashboardSaveModel();
const scene = transformSaveModelToScene(rsp);
expect(scene.state.isEditing).toBe(true);
expect(scene.state.isDirty).toBe(true);
});
});
describe('when organizing panels as scene children', () => { describe('when organizing panels as scene children', () => {
it('should create panels within collapsed rows', () => { it('should create panels within collapsed rows', () => {
const panel = createPanelSaveModel({ const panel = createPanelSaveModel({

View File

@ -248,6 +248,8 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
id: oldModel.id, id: oldModel.id,
description: oldModel.description, description: oldModel.description,
editable: oldModel.editable, editable: oldModel.editable,
isDirty: oldModel.meta.isNew,
isEditing: oldModel.meta.isNew,
meta: oldModel.meta, meta: oldModel.meta,
version: oldModel.version, version: oldModel.version,
body: new SceneGridLayout({ body: new SceneGridLayout({

View File

@ -37,4 +37,13 @@ describe('dashboard utils', () => {
expect(url).toBe('/d/dash-1?orgId=1&new=A'); expect(url).toBe('/d/dash-1?orgId=1&new=A');
}); });
it('Empty uid should be treated as a new dashboard', () => {
const url = getDashboardUrl({
uid: '',
currentQueryParams: '?orgId=1&filter=A',
});
expect(url).toBe('/dashboard/new?orgId=1&filter=A');
});
}); });

View File

@ -27,6 +27,10 @@ export interface DashboardUrlOptions {
export function getDashboardUrl(options: DashboardUrlOptions) { export function getDashboardUrl(options: DashboardUrlOptions) {
let path = `/d/${options.uid}`; let path = `/d/${options.uid}`;
if (!options.uid) {
path = '/dashboard/new';
}
if (options.soloRoute) { if (options.soloRoute) {
path = `/d-solo/${options.uid}`; path = `/d-solo/${options.uid}`;
} }
@ -34,6 +38,7 @@ export function getDashboardUrl(options: DashboardUrlOptions) {
if (options.slug) { if (options.slug) {
path += `/${options.slug}`; path += `/${options.slug}`;
} }
if (options.subPath) { if (options.subPath) {
path += options.subPath; path += options.subPath;
} }

View File

@ -7,7 +7,10 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import {
HOME_DASHBOARD_CACHE_KEY,
getDashboardScenePageStateManager,
} from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { DashboardDTO, DashboardRoutes } from 'app/types'; import { DashboardDTO, DashboardRoutes } from 'app/types';
@ -87,7 +90,7 @@ describe('DashboardPageProxy', () => {
}); });
it('home dashboard', async () => { it('home dashboard', async () => {
getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMock);
act(() => { act(() => {
setup({ setup({
route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' },
@ -105,8 +108,8 @@ describe('DashboardPageProxy', () => {
act(() => { act(() => {
setup({ setup({
route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' },
match: { params: {}, isExact: true, path: '/', url: '/' }, match: { params: { uid: 'abc-def' }, isExact: true, path: '/', url: '/' },
}); });
}); });
@ -116,14 +119,14 @@ describe('DashboardPageProxy', () => {
}); });
}); });
describe('when dashboardSceneForViewers feature toggle enabled', () => { describe('when dashboardSceneForViewers feature toggle enabled', () => {
beforeEach(() => { beforeEach(() => {
config.featureToggles.dashboardSceneForViewers = true; config.featureToggles.dashboardSceneForViewers = true;
}); });
describe('when user can edit a dashboard ', () => { describe('when user can edit a dashboard ', () => {
it('should not render DashboardScenePage if route is Home', async () => { it('should not render DashboardScenePage if route is Home', async () => {
getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMockEditable); getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMockEditable);
act(() => { act(() => {
setup({ setup({
route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' },
@ -152,7 +155,7 @@ describe('DashboardPageProxy', () => {
describe('when user can only view a dashboard ', () => { describe('when user can only view a dashboard ', () => {
it('should render DashboardScenePage if route is Home', async () => { it('should render DashboardScenePage if route is Home', async () => {
getDashboardScenePageStateManager().setDashboardCache(DashboardRoutes.Home, dashMock); getDashboardScenePageStateManager().setDashboardCache(HOME_DASHBOARD_CACHE_KEY, dashMock);
act(() => { act(() => {
setup({ setup({
route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' }, route: { routeName: DashboardRoutes.Home, component: () => null, path: '/' },

View File

@ -32,13 +32,10 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
// To avoid querying single dashboard multiple times, stateManager.fetchDashboard uses a simple, short-lived cache. // To avoid querying single dashboard multiple times, stateManager.fetchDashboard uses a simple, short-lived cache.
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const dashboard = useAsync(async () => { const dashboard = useAsync(async () => {
const dashToFetch = props.route.routeName === DashboardRoutes.Home ? props.route.routeName : props.match.params.uid; return stateManager.fetchDashboard({
route: props.route.routeName as DashboardRoutes,
if (!dashToFetch) { uid: props.match.params.uid ?? '',
return null; });
}
return stateManager.fetchDashboard({ uid: dashToFetch });
}, [props.match.params.uid, props.route.routeName]); }, [props.match.params.uid, props.route.routeName]);
if (!config.featureToggles.dashboardSceneForViewers) { if (!config.featureToggles.dashboardSceneForViewers) {

View File

@ -9,20 +9,16 @@ import store from 'app/core/store';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import {
HOME_DASHBOARD_CACHE_KEY,
getDashboardScenePageStateManager,
} from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel';
import { getFolderByUid } from 'app/features/folders/state/actions'; import { getFolderByUid } from 'app/features/folders/state/actions';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { toStateKey } from 'app/features/variables/utils'; import { toStateKey } from 'app/features/variables/utils';
import { import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
DashboardDTO,
DashboardInitPhase,
DashboardMeta,
DashboardRoutes,
StoreState,
ThunkDispatch,
ThunkResult,
} from 'app/types';
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { initVariablesTransaction } from '../../variables/state/actions'; import { initVariablesTransaction } from '../../variables/state/actions';
@ -44,7 +40,6 @@ export interface InitDashboardArgs {
routeName?: string; routeName?: string;
fixUrl: boolean; fixUrl: boolean;
keybindingSrv: KeybindingSrv; keybindingSrv: KeybindingSrv;
dashboardDto?: DashboardDTO;
} }
async function fetchDashboard( async function fetchDashboard(
@ -63,7 +58,7 @@ async function fetchDashboard(
switch (args.routeName) { switch (args.routeName) {
case DashboardRoutes.Home: { case DashboardRoutes.Home: {
const stateManager = getDashboardScenePageStateManager(); const stateManager = getDashboardScenePageStateManager();
const cachedDashboard = stateManager.getFromCache(DashboardRoutes.Home); const cachedDashboard = stateManager.getFromCache(HOME_DASHBOARD_CACHE_KEY);
if (cachedDashboard) { if (cachedDashboard) {
return cachedDashboard; return cachedDashboard;
@ -88,11 +83,6 @@ async function fetchDashboard(
case DashboardRoutes.Public: { case DashboardRoutes.Public: {
return await dashboardLoaderSrv.loadDashboard('public', args.urlSlug, args.accessToken); return await dashboardLoaderSrv.loadDashboard('public', args.urlSlug, args.accessToken);
} }
case DashboardRoutes.Embedded: {
if (args.dashboardDto) {
return args.dashboardDto;
}
}
case DashboardRoutes.Normal: { case DashboardRoutes.Normal: {
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
@ -130,7 +120,7 @@ async function fetchDashboard(
if (args.urlFolderUid) { if (args.urlFolderUid) {
await dispatch(getFolderByUid(args.urlFolderUid)); await dispatch(getFolderByUid(args.urlFolderUid));
} }
return getNewDashboardModelData(args.urlFolderUid); return buildNewDashboardSaveModel(args.urlFolderUid);
} }
case DashboardRoutes.Path: { case DashboardRoutes.Path: {
const path = args.urlSlug ?? ''; const path = args.urlSlug ?? '';
@ -300,28 +290,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
}; };
} }
export function getNewDashboardModelData(urlFolderUid?: string): { dashboard: any; meta: DashboardMeta } {
const data = {
meta: {
canStar: false,
canShare: false,
canDelete: false,
isNew: true,
folderUid: '',
},
dashboard: {
title: 'New dashboard',
panels: [],
},
};
if (urlFolderUid) {
data.meta.folderUid = urlFolderUid;
}
return data;
}
const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY'; const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) { export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) {

View File

@ -2,10 +2,8 @@ import { DataFrame, ExplorePanelsState } from '@grafana/data';
import { Dashboard, DataQuery, DataSourceRef } from '@grafana/schema'; import { Dashboard, DataQuery, DataSourceRef } from '@grafana/schema';
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen'; import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import { import { setDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
getNewDashboardModelData, import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel';
setDashboardToFetchFromLocalStorage,
} from 'app/features/dashboard/state/initDashboard';
import { DashboardDTO, ExplorePanelData } from 'app/types'; import { DashboardDTO, ExplorePanelData } from 'app/types';
export enum AddToDashboardError { export enum AddToDashboardError {
@ -87,7 +85,7 @@ export async function setDashboardInLocalStorage(options: AddPanelToDashboardOpt
throw AddToDashboardError.FETCH_DASHBOARD; throw AddToDashboardError.FETCH_DASHBOARD;
} }
} else { } else {
dto = getNewDashboardModelData(); dto = buildNewDashboardSaveModel();
} }
dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])]; dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])];

View File

@ -51,6 +51,7 @@ export interface DashboardMeta {
publicDashboardEnabled?: boolean; publicDashboardEnabled?: boolean;
dashboardNotFound?: boolean; dashboardNotFound?: boolean;
isEmbedded?: boolean; isEmbedded?: boolean;
isNew?: boolean;
} }
export interface AnnotationActions { export interface AnnotationActions {