Dashboard: Prevent changing layout to tabs when rows contain tabs (#116019)

- Add containsTabsLayout helper function to check if child layouts contain tabs
- Update DashboardLayoutSelector to disable tabs option when children contain tabs
- Show different tooltip message for parent vs child tabs nesting scenarios
- Add tests for the new functionality
This commit is contained in:
Oscar Kilhed
2026-01-09 08:33:54 +01:00
committed by GitHub
parent 45c25ab1d9
commit 125cc5fddd
5 changed files with 191 additions and 6 deletions
@@ -7,10 +7,13 @@ import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabItem } from '../layout-tabs/TabItem';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { LayoutParent } from '../types/LayoutParent';
import { DashboardLayoutSelector } from './DashboardLayoutSelector';
@@ -40,6 +43,27 @@ describe('DashboardLayoutSelector', () => {
await user.click(confirmButton);
expect(switchLayoutMock).toHaveBeenCalled();
});
it('should disable tabs option when a row contains tabs layout and show correct message', async () => {
const scene = buildTestSceneWithNestedTabs();
const layoutManager = scene.state.body;
render(<DashboardLayoutSelector layoutManager={layoutManager} />);
const tabsOption = screen.getByLabelText('layout-selection-option-Tabs');
expect(tabsOption).toBeDisabled();
expect(screen.getByTitle('Cannot change to tabs because a row already contains tabs')).toBeInTheDocument();
});
it('should not disable tabs option when rows do not contain tabs', async () => {
const scene = buildTestScene();
const layoutManager = scene.state.body;
render(<DashboardLayoutSelector layoutManager={layoutManager} />);
const tabsOption = screen.getByLabelText('layout-selection-option-Tabs');
expect(tabsOption).not.toBeDisabled();
});
});
const buildTestScene = () => {
@@ -70,3 +94,43 @@ const buildTestScene = () => {
activateFullSceneTree(scene);
return scene;
};
const buildTestSceneWithNestedTabs = () => {
const scene = new DashboardScene({
title: 'testScene',
editable: true,
$variables: new SceneVariableSet({
variables: [],
}),
body: new RowsLayoutManager({
rows: [
new RowItem({
title: 'Row 1',
layout: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
body: new VizPanel({ key: 'panel-1', pluginId: 'text' }),
}),
],
}),
}),
}),
new RowItem({
title: 'Row with Tabs',
layout: new TabsLayoutManager({
tabs: [
new TabItem({
title: 'Tab 1',
layout: AutoGridLayoutManager.createEmpty(),
}),
],
}),
}),
],
}),
});
activateFullSceneTree(scene);
return scene;
};
@@ -11,6 +11,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { isLayoutParent } from '../types/LayoutParent';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { containsTabsLayout } from './findAllGridTypes';
import { layoutRegistry } from './layoutRegistry';
export interface Props {
@@ -22,19 +23,26 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
const [newLayout, setNewLayout] = useState<LayoutRegistryItem | undefined>();
const disableTabs = useMemo(() => {
const disableTabsReason = useMemo(() => {
if (config.featureToggles.unlimitedLayoutsNesting) {
return false;
return undefined;
}
// Check parent hierarchy
let parent = layoutManager.parent;
while (parent) {
if (parent instanceof TabsLayoutManager) {
return true;
return 'parent';
}
parent = parent.parent;
}
return false;
// Check child hierarchy
if (containsTabsLayout(layoutManager)) {
return 'child';
}
return undefined;
}, [layoutManager]);
const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []);
@@ -59,8 +67,15 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
const radioOptions = options.map((opt) => {
let description = opt.description;
if (disableTabs && opt.id === TabsLayoutManager.descriptor.id) {
description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs');
if (disableTabsReason && opt.id === TabsLayoutManager.descriptor.id) {
if (disableTabsReason === 'parent') {
description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs');
} else {
description = t(
'dashboard.canvas-actions.disabled-child-contains-tabs',
'Cannot change to tabs because a row already contains tabs'
);
}
disabledOptions.push(opt);
}
@@ -0,0 +1,93 @@
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabItem } from '../layout-tabs/TabItem';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { containsTabsLayout, findAllGridTypes } from './findAllGridTypes';
describe('findAllGridTypes', () => {
it('should return grid type for a grid layout', () => {
const layout = AutoGridLayoutManager.createEmpty();
expect(findAllGridTypes(layout)).toEqual([AutoGridLayoutManager.descriptor.id]);
});
it('should return grid types from tabs', () => {
const layout = new TabsLayoutManager({
tabs: [
new TabItem({ layout: AutoGridLayoutManager.createEmpty() }),
new TabItem({ layout: AutoGridLayoutManager.createEmpty() }),
],
});
expect(findAllGridTypes(layout)).toEqual([
AutoGridLayoutManager.descriptor.id,
AutoGridLayoutManager.descriptor.id,
]);
});
it('should return grid types from rows', () => {
const layout = new RowsLayoutManager({
rows: [
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
],
});
expect(findAllGridTypes(layout)).toEqual([
AutoGridLayoutManager.descriptor.id,
AutoGridLayoutManager.descriptor.id,
]);
});
});
describe('containsTabsLayout', () => {
it('should return true when layout is TabsLayoutManager', () => {
const layout = new TabsLayoutManager({
tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })],
});
expect(containsTabsLayout(layout)).toBe(true);
});
it('should return false when layout is a grid layout', () => {
const layout = AutoGridLayoutManager.createEmpty();
expect(containsTabsLayout(layout)).toBe(false);
});
it('should return false when layout is RowsLayoutManager with no tabs in rows', () => {
const layout = new RowsLayoutManager({
rows: [
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
],
});
expect(containsTabsLayout(layout)).toBe(false);
});
it('should return true when RowsLayoutManager contains a row with tabs layout', () => {
const layout = new RowsLayoutManager({
rows: [
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
new RowItem({
layout: new TabsLayoutManager({
tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })],
}),
}),
],
});
expect(containsTabsLayout(layout)).toBe(true);
});
it('should return true when any row contains tabs layout', () => {
const layout = new RowsLayoutManager({
rows: [
new RowItem({
layout: new TabsLayoutManager({
tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })],
}),
}),
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
new RowItem({ layout: AutoGridLayoutManager.createEmpty() }),
],
});
expect(containsTabsLayout(layout)).toBe(true);
});
});
@@ -15,3 +15,15 @@ export function findAllGridTypes(layout: DashboardLayoutManager): string[] {
return [];
}
export function containsTabsLayout(layout: DashboardLayoutManager): boolean {
if (layout instanceof TabsLayoutManager) {
return true;
}
if (layout instanceof RowsLayoutManager) {
return layout.state.rows.some((row) => containsTabsLayout(row.getLayout()));
}
return false;
}
+1
View File
@@ -4614,6 +4614,7 @@
},
"canvas-actions": {
"add-panel": "Add panel",
"disabled-child-contains-tabs": "Cannot change to tabs because a row already contains tabs",
"disabled-nested-grouping": "Grouping is limited to 2 levels",
"disabled-nested-tabs": "Tabs cannot be nested inside other tabs",
"group-into-row": "Group into row",