Dynamic dashboards: Ungroup tabs (#112332)

* ungroup tabs

* fix i18n

* break out confirmation from utils

* update tests

* click convert in modal in e2e
This commit is contained in:
Oscar Kilhed
2025-10-16 19:52:04 +00:00
committed by GitHub
parent bea45a94f0
commit 2e115d0e93
10 changed files with 300 additions and 130 deletions
@@ -92,8 +92,8 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
// Ungroup
await ungroupPanels(dashboardPage, selectors);
// Ungroup using the new ungroup rows button
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.ungroupRows).click();
// Verify Row title is gone
await expect(dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row'))).toBeHidden();
@@ -112,7 +112,7 @@ test.describe(
).toHaveCount(3);
});
test('can add and remove several rows', async ({ dashboardPage, selectors, page }) => {
test('can add multiple rows and ungroup them all at once', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(page, selectors, 'Add and remove rows');
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -171,6 +171,7 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
// First test individual row deletion
await dashboardPage
.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1'))
.locator('..')
@@ -178,29 +179,38 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.deleteButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 2'))
.locator('..')
.click();
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.deleteButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click();
// Verify one row is deleted
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeHidden();
await expect(thirdRow).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(4); // 3 from first row + 1 from third row
// Now test ungrouping all remaining rows at once
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.ungroupRows).click();
// Handle the ConvertMixedGridsModal that appears when there are mixed grid types
// The modal asks which grid type to convert to - we'll choose "Custom" (GridLayout)
await page.getByRole('button', { name: 'Convert to Custom' }).click();
// Verify all remaining rows are gone and all panels are now in a single grid
await expect(firstRow).toBeHidden();
await expect(thirdRow).toBeHidden();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
).toHaveCount(4); // All 4 panels should be visible in the single grid
await saveDashboard(dashboardPage, selectors);
await page.reload();
await expect(firstRow).toBeVisible();
// Verify all rows are still gone after reload
await expect(firstRow).toBeHidden();
await expect(secondRow).toBeHidden();
await expect(thirdRow).toBeHidden();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
).toHaveCount(4);
});
test('can paste a copied row', async ({ dashboardPage, selectors, page }) => {
@@ -473,7 +483,7 @@ test.describe(
// Ungroup
await ungroupPanels(dashboardPage, selectors); // ungroup tabs
await ungroupPanels(dashboardPage, selectors); // ungroup rows
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.ungroupRows).click(); // ungroup rows
// Verify tab and row titles is gone
await expect(dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row'))).toBeHidden();
@@ -804,7 +814,7 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
// Ungroup
await ungroupPanels(dashboardPage, selectors); // ungroup rows
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.ungroupRows).click(); // ungroup rows
await ungroupPanels(dashboardPage, selectors); // ungroup tabs
// Verify tab and row titles is gone
@@ -33,6 +33,9 @@ export const versionedComponents = {
ungroup: {
'12.1.0': 'data-testid CanvasGridAddActions ungroup',
},
ungroupRows: {
'12.1.0': 'data-testid CanvasGridAddActions ungroup-rows',
},
addRow: {
'12.1.0': 'data-testid CanvasGridAddActions add-row',
},
@@ -9,8 +9,6 @@ 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';
@@ -22,12 +20,12 @@ 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 { showConvertMixedGridsModal, showUngroupConfirmation } from '../layouts-shared/ungroupConfirmation';
import { generateUniqueTitle, ungroupLayout, GridLayoutType, mapIdToGridLayoutType } 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';
@@ -35,22 +33,6 @@ 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;
@@ -171,50 +153,25 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
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)!);
}
showUngroupConfirmation({
hasNonGridLayout,
gridTypes,
onConfirm: (gridLayoutType) => {
this.wrapUngroupRowsInEdit(gridLayoutType);
},
onConvertMixedGrids: (availableIds) => {
this._confirmConvertMixedGrids(availableIds);
},
});
}
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);
}
},
},
})
);
showConvertMixedGridsModal(availableIds, (id: string) => {
const selected = mapIdToGridLayoutType(id);
if (selected) {
this.wrapUngroupRowsInEdit(selected);
}
});
}
private wrapUngroupRowsInEdit(gridLayoutType: GridLayoutType) {
@@ -53,7 +53,13 @@ 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()}>
<Button
icon="layers-slash"
variant="primary"
fill="text"
onClick={() => model.ungroupRows()}
data-testid={selectors.components.CanvasGridAddActions.ungroupRows}
>
<Trans i18nKey="dashboard.canvas-actions.ungroup-rows">Ungroup rows</Trans>
</Button>
<Button
@@ -13,11 +13,16 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import { getDashboardSceneFor } from '../../utils/utils';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { findAllGridTypes } from '../layouts-shared/findAllGridTypes';
import { getTabFromClipboard } from '../layouts-shared/paste';
import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
import { showConvertMixedGridsModal, showUngroupConfirmation } from '../layouts-shared/ungroupConfirmation';
import { generateUniqueTitle, ungroupLayout, GridLayoutType, mapIdToGridLayoutType } from '../layouts-shared/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { isLayoutParent } from '../types/LayoutParent';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { TabItem } from './TabItem';
@@ -66,6 +71,21 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
throw new Error('Method not implemented.');
}
public merge(other: DashboardLayoutManager) {
if (!(other instanceof TabsLayoutManager)) {
throw new Error('Cannot merge non-tabs layout');
}
// Merge all tabs from the other layout into this one
const otherTabs = other.state.tabs;
const mergedTabs = [...this.state.tabs, ...otherTabs];
// Clear parent from merged tabs to avoid conflicts
otherTabs.forEach((tab) => tab.clearParent());
this.setState({ tabs: mergedTabs });
}
public duplicateTab(tab: TabItem) {
const newTab = tab.duplicate();
this.addNewTab(newTab);
@@ -203,36 +223,144 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
return this.state.tabs.length === 1;
}
public removeTab(tabToRemove: TabItem) {
public convertAllTabsLayouts(gridLayoutType: GridLayoutType) {
for (const tab of this.state.tabs) {
switch (gridLayoutType) {
case GridLayoutType.AutoGridLayout:
if (!(tab.getLayout() instanceof AutoGridLayoutManager)) {
tab.switchLayout(AutoGridLayoutManager.createFromLayout(tab.getLayout()));
}
break;
case GridLayoutType.GridLayout:
if (!(tab.getLayout() instanceof DefaultGridLayoutManager)) {
tab.switchLayout(DefaultGridLayoutManager.createFromLayout(tab.getLayout()));
}
break;
}
}
}
public ungroupTabs() {
const hasNonGridLayout = this.state.tabs.some((tab) => !tab.getLayout().descriptor.isGridLayout);
const gridTypes = new Set(findAllGridTypes(this));
showUngroupConfirmation({
hasNonGridLayout,
gridTypes,
onConfirm: (gridLayoutType) => {
this.wrapUngroupTabsInEdit(gridLayoutType);
},
onConvertMixedGrids: (availableIds) => {
this._confirmConvertMixedGrids(availableIds);
},
});
}
private _confirmConvertMixedGrids(availableIds: Set<string>) {
showConvertMixedGridsModal(availableIds, (id: string) => {
const selected = mapIdToGridLayoutType(id);
if (selected) {
this.wrapUngroupTabsInEdit(selected);
}
});
}
private wrapUngroupTabsInEdit(gridLayoutType: GridLayoutType) {
const parent = this.parent;
if (!parent || !isLayoutParent(parent)) {
throw new Error('Ungroup tabs failed: parent is not a layout container');
}
const previousLayout = this.clone({});
const scene = getDashboardSceneFor(this);
dashboardEditActions.edit({
description: t('dashboard.tabs-layout.edit.ungroup-tabs', 'Ungroup tabs'),
source: scene,
perform: () => {
this._ungroupTabs(gridLayoutType);
},
undo: () => {
parent.switchLayout(previousLayout);
},
});
}
private _ungroupTabs(gridLayoutType: GridLayoutType) {
const hasNonGridLayout = this.state.tabs.some((tab) => !tab.getLayout().descriptor.isGridLayout);
if (hasNonGridLayout) {
for (const tab of this.state.tabs) {
const layout = tab.getLayout();
if (!layout.descriptor.isGridLayout) {
if (layout instanceof TabsLayoutManager) {
layout._ungroupTabs(gridLayoutType);
} else if (layout instanceof RowsLayoutManager) {
layout.ungroupRows();
} else {
throw new Error(`Ungrouping not supported for layout type: ${layout.descriptor.name}`);
}
}
}
}
this.convertAllTabsLayouts(gridLayoutType);
const firstTab = this.state.tabs[0];
const firstTabLayout = firstTab.getLayout();
const otherTabs = this.state.tabs.slice(1);
for (const tab of otherTabs) {
const layout = tab.getLayout();
if (firstTabLayout.merge) {
firstTabLayout.merge(layout);
} else {
throw new Error(`Layout type ${firstTabLayout.descriptor.name} does not support merging`);
}
}
this.setState({ tabs: [firstTab] });
this.removeTab(firstTab, true);
}
public removeTab(tabToRemove: TabItem, skipUndo?: boolean) {
// When removing last tab replace ourselves with the inner tab layout
if (this.shouldUngroup()) {
ungroupLayout(this, tabToRemove.state.layout);
ungroupLayout(this, tabToRemove.state.layout, skipUndo ?? false);
return;
}
const tabIndex = this.state.tabs.findIndex((t) => t === tabToRemove);
dashboardEditActions.removeElement({
removedObject: tabToRemove,
source: this,
perform: () => {
const tabs = this.state.tabs;
const tabIndex = tabs.findIndex((t) => t === tabToRemove);
const newCurrentTabIndex = tabIndex > 0 ? tabIndex - 1 : 0;
const perform = () => {
const tabs = this.state.tabs;
const tabIndex = tabs.findIndex((t) => t === tabToRemove);
const newCurrentTabIndex = tabIndex > 0 ? tabIndex - 1 : 0;
const newTabsState = tabs.filter((t) => t !== tabToRemove);
const newTabsState = tabs.filter((t) => t !== tabToRemove);
this.setState({
tabs: newTabsState,
currentTabSlug: newTabsState[newCurrentTabIndex]?.getSlug(),
});
},
undo: () => {
const tabs = [...this.state.tabs];
tabs.splice(tabIndex, 0, tabToRemove);
this.setState({ tabs, currentTabSlug: tabToRemove.getSlug() });
},
});
this.setState({
tabs: newTabsState,
currentTabSlug: newTabsState[newCurrentTabIndex]?.getSlug(),
});
};
const undo = () => {
const tabs = [...this.state.tabs];
tabs.splice(tabIndex, 0, tabToRemove);
this.setState({ tabs, currentTabSlug: tabToRemove.getSlug() });
};
if (skipUndo) {
perform();
} else {
dashboardEditActions.removeElement({
removedObject: tabToRemove,
source: this,
perform,
undo,
});
}
}
public moveTab(fromIndex: number, toIndex: number) {
@@ -92,6 +92,9 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
<Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans>
</Button>
)}
<Button icon="layers-slash" variant="primary" fill="text" onClick={() => model.ungroupTabs()}>
<Trans i18nKey="dashboard.canvas-actions.ungroup-tabs">Ungroup tabs</Trans>
</Button>
</div>
)}
</div>
@@ -11,7 +11,6 @@ import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { DashboardInteractions } from '../../utils/interactions';
import { getDefaultVizPanel } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../types/DashboardLayoutManager';
@@ -155,10 +154,6 @@ function renderUngroupAction(layoutManager: DashboardLayoutManager) {
return <UngroupButtonTabs parentLayout={parentLayout} onClick={onUngroup} />;
}
if (parentLayout instanceof RowsLayoutManager) {
return <UngroupButtonRows parentLayout={parentLayout} onClick={onUngroup} />;
}
return null;
}
@@ -187,26 +182,6 @@ function UngroupButtonTabs({ parentLayout, onClick }: UngroupButtonProps<TabsLay
);
}
function UngroupButtonRows({ parentLayout, onClick }: UngroupButtonProps<RowsLayoutManager>) {
const { rows } = parentLayout.useState();
if (rows.length > 1) {
return null;
}
return (
<Button
variant="primary"
fill="text"
icon="layers-slash"
onClick={onClick}
data-testid={selectors.components.CanvasGridAddActions.ungroup}
>
<Trans i18nKey="dashboard.canvas-actions.un-group-panels">Ungroup</Trans>
</Button>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
addAction: css({
position: 'absolute',
@@ -0,0 +1,67 @@
import { t } from '@grafana/i18n';
import appEvents from 'app/core/app_events';
import { ShowConfirmModalEvent, ShowModalReactEvent } from 'app/types/events';
import { ConvertMixedGridsModal } from '../layout-rows/ConvertMixedGridsModal';
import { GridLayoutType, mapIdToGridLayoutType } from './utils';
export interface UngroupConfirmationOptions {
hasNonGridLayout: boolean;
gridTypes: Set<string>;
onConfirm: (gridLayoutType: GridLayoutType) => void;
onConvertMixedGrids: (availableIds: Set<string>) => void;
}
export function showUngroupConfirmation({
hasNonGridLayout,
gridTypes,
onConfirm,
onConvertMixedGrids,
}: UngroupConfirmationOptions) {
if (hasNonGridLayout) {
appEvents.publish(
new ShowConfirmModalEvent({
title: t('dashboard.layout.ungroup-nested-title', 'Ungroup nested groups?'),
text: t('dashboard.layout.ungroup-nested-text', 'This will ungroup all nested groups.'),
yesText: t('dashboard.layout.continue', 'Continue'),
noText: t('dashboard.layout.cancel', 'Cancel'),
onConfirm: () => {
if (gridTypes.size > 1) {
requestAnimationFrame(() => {
onConvertMixedGrids(gridTypes);
});
} else {
const gridLayoutType = mapIdToGridLayoutType(gridTypes.values().next().value);
if (gridLayoutType) {
onConfirm(gridLayoutType);
}
}
},
})
);
return;
}
if (gridTypes.size > 1) {
onConvertMixedGrids(gridTypes);
return;
} else {
const gridLayoutType = mapIdToGridLayoutType(gridTypes.values().next().value);
if (gridLayoutType) {
onConfirm(gridLayoutType);
}
}
}
export function showConvertMixedGridsModal(availableIds: Set<string>, onSelect: (id: string) => void) {
appEvents.publish(
new ShowModalReactEvent({
component: ConvertMixedGridsModal,
props: {
availableIds,
onSelect,
},
})
);
}
@@ -81,3 +81,19 @@ export function getIsLazy(preload: boolean | undefined): boolean {
// We don't want to lazy load panels in the case of image renderer
return !(preload || (contextSrv.user && contextSrv.user.authenticatedBy === 'render'));
}
export enum GridLayoutType {
AutoGridLayout = 'AutoGridLayout',
GridLayout = 'GridLayout',
}
export function mapIdToGridLayoutType(id?: string): GridLayoutType | undefined {
switch (id) {
case GridLayoutType.AutoGridLayout:
return GridLayoutType.AutoGridLayout;
case GridLayoutType.GridLayout:
return GridLayoutType.GridLayout;
default:
return undefined;
}
}
+11 -6
View File
@@ -4535,7 +4535,8 @@
"paste-row": "Paste row",
"paste-tab": "Paste tab",
"un-group-panels": "Ungroup",
"ungroup-rows": "Ungroup rows"
"ungroup-rows": "Ungroup rows",
"ungroup-tabs": "Ungroup tabs"
},
"conditional-rendering": {
"conditions": {
@@ -5046,6 +5047,7 @@
}
},
"layout": {
"cancel": "Cancel",
"common": {
"copy": "Copy",
"copy-or-duplicate": "Copy or Duplicate",
@@ -5055,7 +5057,10 @@
"group-layout-disabled": "No groups exists on this level",
"panel-layout": "Panel layout",
"panel-layout-disabled": "Select a row or tab to change panel layout options"
}
},
"continue": "Continue",
"ungroup-nested-text": "This will ungroup all nested groups.",
"ungroup-nested-title": "Ungroup nested groups?"
},
"legacy-dashboard-api": {
"fetch-error": {
@@ -5205,7 +5210,6 @@
},
"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?",
@@ -5245,9 +5249,7 @@
"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?"
"ungroup-convert-title": "Convert mixed grids?"
},
"save-dashboard": {
"message-dashboard-saved": "Dashboard saved"
@@ -5337,6 +5339,9 @@
"delete-tab-title": "Delete tab?",
"delete-tab-yes": "Delete",
"description": "Organize panels into horizontal tabs",
"edit": {
"ungroup-tabs": "Ungroup tabs"
},
"name": "Tabs",
"tab": {
"new": "New tab",