diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 0e6ea10a4ed..a9e0cfea3be 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -8,6 +8,7 @@ import { SceneVariableSet, TestVariable, VizPanel, + SceneGridRow, } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import appEvents from 'app/core/app_events'; @@ -25,6 +26,14 @@ import { DashboardScene, DashboardSceneState } from './DashboardScene'; jest.mock('../settings/version-history/HistorySrv'); jest.mock('../serialization/transformSaveModelToScene'); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + }; + }, +})); describe('DashboardScene', () => { describe('DashboardSrv.getCurrent compatibility', () => { @@ -112,6 +121,43 @@ describe('DashboardScene', () => { scene.exitEditMode({ skipConfirm: true }); expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState); }); + + it('Should throw an error when adding a panel to a layout that is not SceneGridLayout', () => { + const scene = buildTestScene({ body: undefined }); + + expect(() => { + scene.addPanel(new VizPanel({ title: 'Panel Title', key: 'panel-4', pluginId: 'timeseries' })); + }).toThrow('Trying to add a panel in a layout that is not SceneGridLayout'); + }); + + it('Should add a new panel to the dashboard', () => { + const vizPanel = new VizPanel({ + title: 'Panel Title', + key: 'panel-4', + pluginId: 'timeseries', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }); + + scene.addPanel(vizPanel); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(scene.state.isDirty).toBe(true); + expect(body.state.children.length).toBe(5); + expect(gridItem.state.body!.state.key).toBe('panel-4'); + }); + + it('Should create and add a new panel to the dashboard', () => { + scene.onCreateNewPanel(); + + const body = scene.state.body as SceneGridLayout; + const gridItem = body.state.children[0] as SceneGridItem; + + expect(scene.state.isDirty).toBe(true); + expect(body.state.children.length).toBe(5); + expect(gridItem.state.body!.state.key).toBe('panel-4'); + }); }); }); @@ -235,6 +281,18 @@ function buildTestScene(overrides?: Partial) { pluginId: 'table', }), }), + new SceneGridRow({ + key: 'gridrow-1', + children: [ + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel C', + key: 'panel-3', + pluginId: 'table', + }), + }), + ], + }), new SceneGridItem({ body: new VizPanel({ title: 'Panel B', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index a8b64713295..9bd46d81d62 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -45,7 +45,15 @@ import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/urlBuilders'; -import { forceRenderChildren, getClosestVizPanel, getPanelIdForVizPanel, isPanelClone } from '../utils/utils'; +import { + NEW_PANEL_HEIGHT, + NEW_PANEL_WIDTH, + forceRenderChildren, + getClosestVizPanel, + getDefaultVizPanel, + getPanelIdForVizPanel, + isPanelClone, +} from '../utils/utils'; import { DashboardControls } from './DashboardControls'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; @@ -416,20 +424,29 @@ export class DashboardScene extends SceneObjectBase { } public addPanel(vizPanel: VizPanel): void { - // TODO: need logic for adding a panel when other panels exist - // This is the logic when dashboard is empty - this.setState({ - body: new SceneGridLayout({ - children: [ - new SceneGridItem({ - height: 10, - width: 10, - x: 0.2, - y: 0, - body: vizPanel, - }), - ], - }), + if (!(this.state.body instanceof SceneGridLayout)) { + throw new Error('Trying to add a panel in a layout that is not SceneGridLayout'); + } + + const sceneGridLayout = this.state.body; + + // move all gridItems below the new one + for (const child of sceneGridLayout.state.children) { + child.setState({ + y: NEW_PANEL_HEIGHT + (child.state.y ?? 0), + }); + } + + const newGridItem = new SceneGridItem({ + height: NEW_PANEL_HEIGHT, + width: NEW_PANEL_WIDTH, + x: 0, + y: 0, + body: vizPanel, + }); + + sceneGridLayout.setState({ + children: [newGridItem, ...sceneGridLayout.state.children], }); } @@ -523,6 +540,14 @@ export class DashboardScene extends SceneObjectBase { locationService.partial({ editview: 'settings' }); }; + public onCreateNewPanel(): number { + const vizPanel = getDefaultVizPanel(this); + + this.addPanel(vizPanel); + + return getPanelIdForVizPanel(vizPanel); + } + /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 993c1e97a7b..6d95fcccca3 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -15,6 +15,7 @@ describe('NavToolbarActions', () => { setup(); expect(screen.queryByText('Save dashboard')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add visualization')).not.toBeInTheDocument(); expect(await screen.findByText('Edit')).toBeInTheDocument(); expect(await screen.findByText('Share')).toBeInTheDocument(); }); @@ -26,6 +27,7 @@ describe('NavToolbarActions', () => { expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); expect(await screen.findByText('Exit edit')).toBeInTheDocument(); + expect(await screen.findByLabelText('Add visualization')).toBeInTheDocument(); expect(screen.queryByText('Edit')).not.toBeInTheDocument(); expect(screen.queryByText('Share')).not.toBeInTheDocument(); }); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 540bc7fefb0..02164f9f1f7 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -34,7 +34,7 @@ export const NavToolbarActions = React.memo(({ dashboard }) => { NavToolbarActions.displayName = 'NavToolbarActions'; /** - * This part is split into a separate componet to help test this + * This part is split into a separate component to help test this */ export function ToolbarActions({ dashboard }: Props) { const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel } = dashboard.useState(); @@ -46,7 +46,24 @@ export function ToolbarActions({ dashboard }: Props) { toolbarActions.push({ group: 'icon-actions', - condition: uid && !editview && Boolean(meta.canStar) && !isEditingPanel, + condition: isEditing && !editview && !isViewingPanel && !isEditingPanel, + render: () => ( + { + const id = dashboard.onCreateNewPanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); + locationService.partial({ editPanel: id }); + }} + /> + ), + }); + + toolbarActions.push({ + group: 'icon-actions', + condition: uid && !editview && Boolean(meta.canStar) && !isEditingPanel && !isEditing, render: () => { let desc = meta.isStarred ? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite') diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 286659e7bb8..b34f5103b9e 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -4,6 +4,9 @@ import { MultiValueVariable, SceneDataTransformer, sceneGraph, + SceneGridItem, + SceneGridLayout, + SceneGridRow, SceneObject, SceneQueryRunner, VizPanel, @@ -15,6 +18,9 @@ import { DashboardScene } from '../scene/DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; +export const NEW_PANEL_HEIGHT = 8; +export const NEW_PANEL_WIDTH = 12; + export function getVizPanelKeyForPanelId(panelId: number) { return `panel-${panelId}`; } @@ -194,10 +200,51 @@ export function isPanelClone(key: string) { return key.includes('clone'); } -export function onCreateNewPanel(dashboard: DashboardScene): number { - const vizPanel = new VizPanel({ +export function getNextPanelId(dashboard: DashboardScene) { + let max = 0; + const body = dashboard.state.body; + + if (body instanceof SceneGridLayout) { + for (const child of body.state.children) { + if (child instanceof SceneGridItem) { + const vizPanel = child.state.body; + + if (vizPanel instanceof VizPanel) { + const panelId = getPanelIdForVizPanel(vizPanel); + + if (panelId > max) { + max = panelId; + } + } + } + + if (child instanceof SceneGridRow) { + for (const rowChild of child.state.children) { + if (rowChild instanceof SceneGridItem) { + const vizPanel = rowChild.state.body; + + if (vizPanel instanceof VizPanel) { + const panelId = getPanelIdForVizPanel(vizPanel); + + if (panelId > max) { + max = panelId; + } + } + } + } + } + } + } + + return max + 1; +} + +export function getDefaultVizPanel(dashboard: DashboardScene): VizPanel { + const panelId = getNextPanelId(dashboard); + + return new VizPanel({ title: 'Panel Title', - key: 'panel-1', // the first panel should always be panel-1 + key: getVizPanelKeyForPanelId(panelId), pluginId: 'timeseries', titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], menu: new VizPanelMenu({ @@ -211,8 +258,4 @@ export function onCreateNewPanel(dashboard: DashboardScene): number { transformations: [], }), }); - dashboard.addPanel(vizPanel); - const id = getPanelIdForVizPanel(vizPanel); - - return id; } diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx index 3ac12389b66..f30bcf95a55 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx @@ -10,7 +10,6 @@ import { DashboardModel } from 'app/features/dashboard/state'; import { onAddLibraryPanel, onCreateNewPanel, onImportDashboard } from 'app/features/dashboard/utils/dashboard'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; -import { onCreateNewPanel as onCreateNewPanelScene } from 'app/features/dashboard-scene/utils/utils'; import { useDispatch, useSelector } from 'app/types'; import { setInitialDatasource } from '../state/reducers'; @@ -24,12 +23,11 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); - const isDashboardScene = dashboard instanceof DashboardScene; const onAddVisualization = () => { let id; - if (isDashboardScene) { - id = onCreateNewPanelScene(dashboard); + if (dashboard instanceof DashboardScene) { + id = dashboard.onCreateNewPanel(); } else { id = onCreateNewPanel(dashboard, initialDatasource); dispatch(setInitialDatasource(undefined)); @@ -114,7 +112,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')} onClick={() => { DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_from_library' }); - if (isDashboardScene) { + if (dashboard instanceof DashboardScene) { // TODO: dashboard scene logic for adding a library panel } else { onAddLibraryPanel(dashboard);