From 2e115d0e9341ae24bdcf10c72d4a46e410a9e04d Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 16 Oct 2025 21:52:04 +0200 Subject: [PATCH] Dynamic dashboards: Ungroup tabs (#112332) * ungroup tabs * fix i18n * break out confirmation from utils * update tests * click convert in modal in e2e --- .../dashboard-group-panels.spec.ts | 40 ++-- .../src/selectors/components.ts | 3 + .../scene/layout-rows/RowsLayoutManager.tsx | 79 ++------ .../layout-rows/RowsLayoutManagerRenderer.tsx | 8 +- .../scene/layout-tabs/TabsLayoutManager.tsx | 172 +++++++++++++++--- .../layout-tabs/TabsLayoutManagerRenderer.tsx | 3 + .../layouts-shared/CanvasGridAddActions.tsx | 25 --- .../layouts-shared/ungroupConfirmation.tsx | 67 +++++++ .../scene/layouts-shared/utils.ts | 16 ++ public/locales/en-US/grafana.json | 17 +- 10 files changed, 300 insertions(+), 130 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layouts-shared/ungroupConfirmation.tsx diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts index a5b996713b7..c2bde611cdf 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -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 diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 9dc1afc8a89..3ee3b293200 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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', }, 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 fbefcfbbc64..69e57d98b22 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -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 implements DashboardLayoutManager { public static Component = RowLayoutManagerRenderer; public readonly isDashboardLayoutManager = true; @@ -171,50 +153,25 @@ export class RowsLayoutManager extends SceneObjectBase 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) { - 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) { 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 d7de6c42413..af902374af6 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -53,7 +53,13 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps - )} + )} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx index d5d43c90ca1..dd5c4ac20b6 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx @@ -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 ; } - if (parentLayout instanceof RowsLayoutManager) { - return ; - } - return null; } @@ -187,26 +182,6 @@ function UngroupButtonTabs({ parentLayout, onClick }: UngroupButtonProps) { - const { rows } = parentLayout.useState(); - - if (rows.length > 1) { - return null; - } - - return ( - - ); -} - const getStyles = (theme: GrafanaTheme2) => ({ addAction: css({ position: 'absolute', diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/ungroupConfirmation.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/ungroupConfirmation.tsx new file mode 100644 index 00000000000..83be1a668f5 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/ungroupConfirmation.tsx @@ -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; + onConfirm: (gridLayoutType: GridLayoutType) => void; + onConvertMixedGrids: (availableIds: Set) => 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, onSelect: (id: string) => void) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConvertMixedGridsModal, + props: { + availableIds, + onSelect, + }, + }) + ); +} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts index fa1cc4b6ae5..2c1bd487a56 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts @@ -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; + } +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 05f1d942d9a..3db658c9eac 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",