Dashboard: Limit panel grouping depth (#112216)
This commit is contained in:
@@ -396,6 +396,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
dashboardUndoRedo?: boolean;
|
||||
/**
|
||||
* Enables unlimited dashboard panel grouping
|
||||
*/
|
||||
unlimitedLayoutsNesting?: boolean;
|
||||
/**
|
||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
*/
|
||||
panelFilterVariable?: boolean;
|
||||
|
||||
@@ -657,6 +657,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "unlimitedLayoutsNesting",
|
||||
Description: "Enables unlimited dashboard panel grouping",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
|
||||
@@ -87,6 +87,7 @@ dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,true
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
|
||||
|
@@ -359,6 +359,10 @@ const (
|
||||
// Enables undo/redo in dynamic dashboards
|
||||
FlagDashboardUndoRedo = "dashboardUndoRedo"
|
||||
|
||||
// FlagUnlimitedLayoutsNesting
|
||||
// Enables unlimited dashboard panel grouping
|
||||
FlagUnlimitedLayoutsNesting = "unlimitedLayoutsNesting"
|
||||
|
||||
// FlagPanelFilterVariable
|
||||
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
FlagPanelFilterVariable = "panelFilterVariable"
|
||||
|
||||
@@ -3897,6 +3897,19 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unlimitedLayoutsNesting",
|
||||
"resourceVersion": "1760013838902",
|
||||
"creationTimestamp": "2025-10-09T12:43:58Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables unlimited dashboard panel grouping",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "useKubernetesShortURLsAPI",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
|
||||
@@ -11,7 +13,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { DashboardLayoutManager, isDashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
|
||||
import { addNewRowTo, addNewTabTo } from './addNew';
|
||||
import { useClipboardState } from './useClipboardState';
|
||||
@@ -25,6 +27,38 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { hasCopiedPanel } = useClipboardState();
|
||||
|
||||
const { disableGrouping, disableTabs } = useMemo(() => {
|
||||
if (config.featureToggles.unlimitedLayoutsNesting) {
|
||||
return { disableGrouping: false, disableTabs: false };
|
||||
}
|
||||
|
||||
let parent = layoutManager.parent;
|
||||
const layouts = [];
|
||||
|
||||
while (parent) {
|
||||
if (isDashboardLayoutManager(parent)) {
|
||||
layouts.push(parent.descriptor.id);
|
||||
}
|
||||
|
||||
if (layouts.length === 2) {
|
||||
parent = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
if (layouts.length === 2) {
|
||||
return { disableGrouping: true, disableTabs: true };
|
||||
}
|
||||
|
||||
if (layouts.length === 1 && layouts[0] === TabsLayoutManager.descriptor.id) {
|
||||
return { disableGrouping: false, disableTabs: true };
|
||||
}
|
||||
|
||||
return { disableGrouping: false, disableTabs: false };
|
||||
}, [layoutManager]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
|
||||
<Button
|
||||
@@ -55,6 +89,12 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
|
||||
icon="layers"
|
||||
testId={selectors.components.CanvasGridAddActions.addTab}
|
||||
label={t('dashboard.canvas-actions.group-into-tab', 'Group into tab')}
|
||||
disabled={disableTabs}
|
||||
description={
|
||||
disableTabs
|
||||
? t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs')
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
addNewTabTo(layoutManager);
|
||||
DashboardInteractions.trackGroupTabClick();
|
||||
@@ -68,6 +108,12 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
|
||||
fill="text"
|
||||
icon="layers"
|
||||
data-testid={selectors.components.CanvasGridAddActions.groupPanels}
|
||||
disabled={disableGrouping}
|
||||
tooltip={
|
||||
disableGrouping
|
||||
? t('dashboard.canvas-actions.disabled-nested-grouping', 'Grouping is limited to 2 levels')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.canvas-actions.group-panels">Group panels</Trans>
|
||||
</Button>
|
||||
|
||||
+41
-8
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RadioButtonGroup, Box } from '@grafana/ui';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { isLayoutParent } from '../types/LayoutParent';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
@@ -19,6 +21,21 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
const isGridLayout = layoutManager.descriptor.isGridLayout;
|
||||
const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
|
||||
|
||||
const disableTabs = useMemo(() => {
|
||||
if (config.featureToggles.unlimitedLayoutsNesting) {
|
||||
return false;
|
||||
}
|
||||
let parent = layoutManager.parent;
|
||||
while (parent) {
|
||||
if (parent instanceof TabsLayoutManager) {
|
||||
return true;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [layoutManager]);
|
||||
|
||||
const onChangeLayout = useCallback(
|
||||
(newLayout: LayoutRegistryItem) => {
|
||||
const layoutParent = layoutManager.parent;
|
||||
@@ -30,17 +47,33 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
[layoutManager]
|
||||
);
|
||||
|
||||
const radioOptions = options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt.name,
|
||||
icon: opt.icon,
|
||||
description: opt.description,
|
||||
ariaLabel: `layout-selection-option-${opt.name}`,
|
||||
}));
|
||||
const disabledOptions: LayoutRegistryItem[] = [];
|
||||
|
||||
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');
|
||||
disabledOptions.push(opt);
|
||||
}
|
||||
|
||||
return {
|
||||
value: opt,
|
||||
label: opt.name,
|
||||
icon: opt.icon,
|
||||
description,
|
||||
ariaLabel: `layout-selection-option-${opt.name}`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box paddingBottom={2} display="flex" grow={1} alignItems="center">
|
||||
<RadioButtonGroup fullWidth value={layoutManager.descriptor} options={radioOptions} onChange={onChangeLayout} />
|
||||
<RadioButtonGroup
|
||||
fullWidth
|
||||
value={layoutManager.descriptor}
|
||||
options={radioOptions}
|
||||
onChange={onChangeLayout}
|
||||
disabledOptions={disabledOptions}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4496,6 +4496,8 @@
|
||||
},
|
||||
"canvas-actions": {
|
||||
"add-panel": "Add panel",
|
||||
"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",
|
||||
"group-into-tab": "Group into tab",
|
||||
"group-panels": "Group panels",
|
||||
|
||||
Reference in New Issue
Block a user