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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user