From cfaeec4854cd9276323dcf62ba5005d8678baa3d Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Fri, 10 Oct 2025 10:30:33 +0200 Subject: [PATCH] Dynamic dashboards: Ungroup all rows in a row layout to increase discoverability of ungrouping rows. (#110109) * spike * Improvements * Let users choose what grid to convert to * fix lint * make sure we don't get multiple undo entries when ungrouping. Also move cancel button * updates from review * Clear parent when merging default grid --- .../dashboard-scene/scene/DashboardScene.tsx | 21 ++- .../AutoGridLayoutManager.tsx | 16 ++ .../DefaultGridLayoutManager.tsx | 29 +++ .../layout-rows/ConvertMixedGridsModal.tsx | 48 +++++ .../scene/layout-rows/RowsLayoutManager.tsx | 178 ++++++++++++++++-- .../layout-rows/RowsLayoutManagerRenderer.tsx | 3 + .../scene/layouts-shared/findAllGridTypes.ts | 17 ++ .../scene/layouts-shared/utils.ts | 4 +- .../scene/types/DashboardLayoutManager.ts | 5 + .../scene/types/LayoutParent.ts | 5 +- public/locales/en-US/grafana.json | 15 +- 11 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/ConvertMixedGridsModal.tsx create mode 100644 public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index f918af82afa..61b5e7aa948 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -629,15 +629,20 @@ export class DashboardScene extends SceneObjectBase impleme return vizPanel; } - public switchLayout(layout: DashboardLayoutManager) { + public switchLayout(layout: DashboardLayoutManager, skipUndo?: boolean) { const currentLayout = this.state.body; - - dashboardEditActions.edit({ - description: t('dashboard.edit-actions.switch-layout', 'Switch layout'), - source: this, - perform: () => this.setState({ body: layout }), - undo: () => this.setState({ body: currentLayout }), - }); + const perform = () => this.setState({ body: layout }); + const undo = () => this.setState({ body: currentLayout }); + if (skipUndo) { + perform(); + } else { + dashboardEditActions.edit({ + description: t('dashboard.edit-actions.switch-layout', 'Switch layout'), + source: this, + perform, + undo, + }); + } } public getLayout(): DashboardLayoutManager { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index 397c0ce6bb8..a83da646ca8 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -200,6 +200,22 @@ export class AutoGridLayoutManager }); } + public merge(other: DashboardLayoutManager) { + if (!(other instanceof AutoGridLayoutManager)) { + throw new Error('Cannot merge non-auto grid layout'); + } + + const sourceLayout = other.state.layout; + const movedChildren = [...sourceLayout.state.children]; + + // Remove from source and append to destination + sourceLayout.setState({ children: [] }); + movedChildren.forEach((child) => { + child.clearParent(); + }); + this.state.layout.setState({ children: [...this.state.layout.state.children, ...movedChildren] }); + } + public duplicatePanel(panel: VizPanel) { const gridItem = panel.parent; if (!(gridItem instanceof AutoGridItem)) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index ad3930e1aab..886dd0a9391 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -93,6 +93,35 @@ export class DefaultGridLayoutManager this.addActivationHandler(() => this._activationHandler()); } + public merge(other: DashboardLayoutManager) { + if (!(other instanceof DefaultGridLayoutManager)) { + throw new Error('Cannot merge non-default grid layout'); + } + + let offset = 0; + for (const child of this.state.grid.state.children) { + const newOffset = (child.state.y ?? 0) + (child.state.height ?? 0); + if (newOffset > offset) { + offset = newOffset; + } + } + + const sourceGrid = other.state.grid; + const movedChildren = [...sourceGrid.state.children]; + + for (const child of movedChildren) { + const currentY = child.state.y ?? 0; + child.setState({ y: currentY + offset }); + } + + // Remove from source and append to destination + sourceGrid.setState({ children: [] }); + for (const child of movedChildren) { + child.clearParent(); + } + this.state.grid.setState({ children: [...this.state.grid.state.children, ...movedChildren] }); + } + private _activationHandler() { if (config.featureToggles.dashboardNewLayouts) { this._subs.add( diff --git a/public/app/features/dashboard-scene/scene/layout-rows/ConvertMixedGridsModal.tsx b/public/app/features/dashboard-scene/scene/layout-rows/ConvertMixedGridsModal.tsx new file mode 100644 index 00000000000..d13eb273522 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-rows/ConvertMixedGridsModal.tsx @@ -0,0 +1,48 @@ +import { t, Trans } from '@grafana/i18n'; +import { Modal, Button } from '@grafana/ui'; + +import { layoutRegistry } from '../layouts-shared/layoutRegistry'; + +interface ConvertMixedGridsModalProps { + availableIds: Set; + onSelect: (id: string) => void; + onDismiss: () => void; +} + +export function ConvertMixedGridsModal({ availableIds, onSelect, onDismiss }: ConvertMixedGridsModalProps) { + const options = layoutRegistry.list(Array.from(availableIds)); + + return ( + +

+ + All grids must be converted to the same type and positions will be lost. + +

+ + + {options.map((opt) => ( + + ))} + +
+ ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index d021eac7889..fbefcfbbc64 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -9,19 +9,25 @@ import { VizPanel, } from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import appEvents from 'app/core/app_events'; +import { ShowConfirmModalEvent, ShowModalReactEvent } from 'app/types/events'; import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared'; import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; import { getDashboardSceneFor } from '../../utils/utils'; +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; +import { findAllGridTypes } from '../layouts-shared/findAllGridTypes'; import { getRowFromClipboard } from '../layouts-shared/paste'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { isLayoutParent } from '../types/LayoutParent'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; +import { ConvertMixedGridsModal } from './ConvertMixedGridsModal'; import { RowItem } from './RowItem'; import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer'; @@ -29,6 +35,22 @@ interface RowsLayoutManagerState extends SceneObjectState { rows: RowItem[]; } +enum GridLayoutType { + AutoGridLayout = 'AutoGridLayout', + GridLayout = 'GridLayout', +} + +function mapIdToGridLayoutType(id?: string): GridLayoutType | undefined { + switch (id) { + case GridLayoutType.AutoGridLayout: + return GridLayoutType.AutoGridLayout; + case GridLayoutType.GridLayout: + return GridLayoutType.GridLayout; + default: + return undefined; + } +} + export class RowsLayoutManager extends SceneObjectBase implements DashboardLayoutManager { public static Component = RowLayoutManagerRenderer; public readonly isDashboardLayoutManager = true; @@ -128,25 +150,155 @@ export class RowsLayoutManager extends SceneObjectBase i return outlineChildren; } - public removeRow(row: RowItem) { + public convertAllRowsLayouts(gridLayoutType: GridLayoutType) { + for (const row of this.state.rows) { + switch (gridLayoutType) { + case GridLayoutType.AutoGridLayout: + if (!(row.getLayout() instanceof AutoGridLayoutManager)) { + row.switchLayout(AutoGridLayoutManager.createFromLayout(row.getLayout())); + } + break; + case GridLayoutType.GridLayout: + if (!(row.getLayout() instanceof DefaultGridLayoutManager)) { + row.switchLayout(DefaultGridLayoutManager.createFromLayout(row.getLayout())); + } + break; + } + } + } + + public ungroupRows() { + const hasNonGridLayout = this.state.rows.some((row) => !row.getLayout().descriptor.isGridLayout); + const gridTypes = new Set(findAllGridTypes(this)); + + if (hasNonGridLayout) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: t('dashboard.rows-layout.ungroup-nested-title', 'Ungroup nested groups?'), + text: t('dashboard.rows-layout.ungroup-nested-text', 'This will ungroup all nested groups.'), + yesText: t('dashboard.rows-layout.continue', 'Continue'), + noText: t('dashboard.rows-layout.cancel', 'Cancel'), + onConfirm: () => { + if (gridTypes.size > 1) { + requestAnimationFrame(() => { + this._confirmConvertMixedGrids(gridTypes); + }); + } else { + this.wrapUngroupRowsInEdit(mapIdToGridLayoutType(gridTypes.values().next().value)!); + } + }, + }) + ); + return; + } + + if (gridTypes.size > 1) { + this._confirmConvertMixedGrids(gridTypes); + return; + } else { + this.wrapUngroupRowsInEdit(mapIdToGridLayoutType(gridTypes.values().next().value)!); + } + } + + private _confirmConvertMixedGrids(availableIds: Set) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConvertMixedGridsModal, + props: { + availableIds, + onSelect: (id: string) => { + const selected = mapIdToGridLayoutType(id); + if (selected) { + this.wrapUngroupRowsInEdit(selected); + } + }, + }, + }) + ); + } + + private wrapUngroupRowsInEdit(gridLayoutType: GridLayoutType) { + const parent = this.parent; + if (!parent || !isLayoutParent(parent)) { + throw new Error('Ungroup rows failed: parent is not a layout container'); + } + + const previousLayout = this.clone({}); + const scene = getDashboardSceneFor(this); + + dashboardEditActions.edit({ + description: t('dashboard.rows-layout.edit.ungroup-rows', 'Ungroup rows'), + source: scene, + perform: () => { + this._ungroupRows(gridLayoutType); + }, + undo: () => { + parent.switchLayout(previousLayout); + }, + }); + } + + private _ungroupRows(gridLayoutType: GridLayoutType) { + const hasNonGridLayout = this.state.rows.some((row) => !row.getLayout().descriptor.isGridLayout); + + if (hasNonGridLayout) { + for (const row of this.state.rows) { + const layout = row.getLayout(); + if (!layout.descriptor.isGridLayout) { + if (layout instanceof RowsLayoutManager) { + layout._ungroupRows(gridLayoutType); + } else { + throw new Error(`Ungrouping not supported for layout type: ${layout.descriptor.name}`); + } + } + } + } + + this.convertAllRowsLayouts(gridLayoutType); + + const firstRow = this.state.rows[0]; + const firstRowLayout = firstRow.getLayout(); + const otherRows = this.state.rows.slice(1); + + for (const row of otherRows) { + const layout = row.getLayout(); + if (firstRowLayout.merge) { + firstRowLayout.merge(layout); + } else { + throw new Error(`Layout type ${firstRowLayout.descriptor.name} does not support merging`); + } + } + + this.setState({ rows: [firstRow] }); + this.removeRow(firstRow, true); + } + + public removeRow(row: RowItem, skipUndo?: boolean) { // When removing last row replace ourselves with the inner row layout if (this.shouldUngroup()) { - ungroupLayout(this, row.state.layout); + ungroupLayout(this, row.state.layout, skipUndo ?? false); return; } const indexOfRowToRemove = this.state.rows.findIndex((r) => r === row); - dashboardEditActions.removeElement({ - removedObject: row, - source: this, - perform: () => this.setState({ rows: this.state.rows.filter((r) => r !== row) }), - undo: () => { - const rows = [...this.state.rows]; - rows.splice(indexOfRowToRemove, 0, row); - this.setState({ rows }); - }, - }); + const perform = () => this.setState({ rows: this.state.rows.filter((r) => r !== row) }); + const undo = () => { + const rows = [...this.state.rows]; + rows.splice(indexOfRowToRemove, 0, row); + this.setState({ rows }); + }; + + if (skipUndo) { + perform(); + } else { + dashboardEditActions.removeElement({ + removedObject: row, + source: this, + perform, + undo, + }); + } } public moveRow(_rowKey: string, fromIndex: number, toIndex: number) { @@ -212,7 +364,7 @@ export class RowsLayoutManager extends SceneObjectBase i if (child instanceof SceneGridRow) { // Skip repeated row clones - if (child.state.repeatSourceKey) { + if ('repeatSourceKey' in child.state && child.state.repeatSourceKey) { return; } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index b18634bdd00..d7de6c42413 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -53,6 +53,9 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps +