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
This commit is contained in:
Oscar Kilhed
2025-10-10 10:30:33 +02:00
committed by GitHub
parent 01ec8e3a4a
commit cfaeec4854
11 changed files with 314 additions and 27 deletions
@@ -629,15 +629,20 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> 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 {
@@ -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)) {
@@ -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(
@@ -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<string>;
onSelect: (id: string) => void;
onDismiss: () => void;
}
export function ConvertMixedGridsModal({ availableIds, onSelect, onDismiss }: ConvertMixedGridsModalProps) {
const options = layoutRegistry.list(Array.from(availableIds));
return (
<Modal
isOpen={true}
title={t('dashboard.rows-layout.ungroup-convert-title', 'Convert mixed grids?')}
onDismiss={onDismiss}
>
<p>
<Trans i18nKey="dashboard.rows-layout.ungroup-convert-text">
All grids must be converted to the same type and positions will be lost.
</Trans>
</p>
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
<Trans i18nKey="dashboard.rows-layout.cancel">Cancel</Trans>
</Button>
{options.map((opt) => (
<Button
icon={opt.icon}
key={opt.id}
variant="primary"
onClick={() => {
onSelect(opt.id);
onDismiss();
}}
>
<Trans i18nKey="dashboard.rows-layout.convert-to" values={{ name: opt.name }}>
Convert to {'{{name}}'}
</Trans>
</Button>
))}
</Modal.ButtonRow>
</Modal>
);
}
@@ -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<RowsLayoutManagerState> implements DashboardLayoutManager {
public static Component = RowLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
@@ -128,25 +150,155 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> 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<string>) {
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<RowsLayoutManagerState> i
if (child instanceof SceneGridRow) {
// Skip repeated row clones
if (child.state.repeatSourceKey) {
if ('repeatSourceKey' in child.state && child.state.repeatSourceKey) {
return;
}
@@ -53,6 +53,9 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
{dropProvided.placeholder}
{isEditing && !isClone && (
<div className="dashboard-canvas-add-button">
<Button icon="layers-slash" variant="primary" fill="text" onClick={() => model.ungroupRows()}>
<Trans i18nKey="dashboard.canvas-actions.ungroup-rows">Ungroup rows</Trans>
</Button>
<Button
icon="plus"
variant="primary"
@@ -0,0 +1,17 @@
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
export function findAllGridTypes(layout: DashboardLayoutManager): string[] {
if (layout.descriptor.isGridLayout) {
return [layout.descriptor.id];
}
if (layout instanceof TabsLayoutManager) {
return layout.state.tabs.flatMap((tab) => findAllGridTypes(tab.getLayout()));
} else if (layout instanceof RowsLayoutManager) {
return layout.state.rows.flatMap((row) => findAllGridTypes(row.getLayout()));
}
return [];
}
@@ -69,11 +69,11 @@ export function generateUniqueTitle(title: string | undefined, existingTitles: S
return baseTitle;
}
export function ungroupLayout(layout: DashboardLayoutManager, innerLayout: DashboardLayoutManager) {
export function ungroupLayout(layout: DashboardLayoutManager, innerLayout: DashboardLayoutManager, skipUndo?: boolean) {
const layoutParent = layout.parent!;
if (isLayoutParent(layoutParent)) {
innerLayout.clearParent();
layoutParent.switchLayout(innerLayout);
layoutParent.switchLayout(innerLayout, skipUndo);
}
}
@@ -86,6 +86,11 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
* Get children for outline
*/
getOutlineChildren(): SceneObject[];
/**
* Merge the layout with another layout
*/
merge?(other: DashboardLayoutManager): void;
}
export interface LayoutManagerSerializer {
@@ -13,9 +13,10 @@ export interface LayoutParent extends SceneObject {
/**
* Switches the inner layout manager
* @param newLayout
* @param newLayout The new layout manager to switch to
* @param skipUndo If true, skips creating an undo entry for this operation
*/
switchLayout(newLayout: DashboardLayoutManager): void;
switchLayout(newLayout: DashboardLayoutManager, skipUndo?: boolean): void;
}
export function isLayoutParent(obj: SceneObject): obj is LayoutParent {
+13 -2
View File
@@ -4504,7 +4504,8 @@
"paste-panel": "Paste panel",
"paste-row": "Paste row",
"paste-tab": "Paste tab",
"un-group-panels": "Ungroup"
"un-group-panels": "Ungroup",
"ungroup-rows": "Ungroup rows"
},
"conditional-rendering": {
"conditions": {
@@ -5167,10 +5168,16 @@
"title-row-options": "Row options"
},
"rows-layout": {
"cancel": "Cancel",
"continue": "Continue",
"convert-to": "Convert to {{name}}",
"delete-row-text": "Deleting this row will also remove all panels. Are you sure you want to continue?",
"delete-row-title": "Delete row?",
"delete-row-yes": "Delete",
"description": "Collapsable panel groups with headings",
"edit": {
"ungroup-rows": "Ungroup rows"
},
"header-hidden-tooltip": "Row header only visible in edit mode",
"name": "Rows",
"row": {
@@ -5200,7 +5207,11 @@
},
"row-warning": {
"title-not-unique": "This title is not unique"
}
},
"ungroup-convert-text": "All grids must be converted to the same type and positions will be lost.",
"ungroup-convert-title": "Convert mixed grids?",
"ungroup-nested-text": "This will ungroup all nested groups.",
"ungroup-nested-title": "Ungroup nested groups?"
},
"save-dashboard": {
"message-dashboard-saved": "Dashboard saved"