+
),
});
+ leftActions.push({
+ group: 'add-panel',
+ condition: isEditingAndShowingDashboard,
+ render: () => (
+
+ ),
+ });
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
index 1ee8080fd64..567df0709bf 100644
--- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
@@ -26,6 +26,7 @@ import {
getGridItemKeyForPanelId,
getDashboardSceneFor,
} from '../../utils/utils';
+import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { DashboardGridItem } from './DashboardGridItem';
@@ -177,6 +178,22 @@ export class DefaultGridLayoutManager
return panels;
}
+ public hasVizPanels(): boolean {
+ for (const child of this.state.grid.state.children) {
+ if (child instanceof DashboardGridItem) {
+ return true;
+ } else if (child instanceof SceneGridRow) {
+ for (const rowChild of child.state.children) {
+ if (rowChild instanceof DashboardGridItem) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
public addNewRow(): SceneGridRow {
const id = dashboardSceneGraph.getNextPanelId(this);
@@ -205,6 +222,17 @@ export class DefaultGridLayoutManager
return row;
}
+ public addNewTab() {
+ const shouldAddTab = this.hasVizPanels();
+ const tabsLayout = TabsLayoutManager.createFromLayout(this);
+
+ if (shouldAddTab) {
+ tabsLayout.addNewTab();
+ }
+
+ getDashboardSceneFor(this).switchLayout(tabsLayout);
+ }
+
public editModeChanged(isEditing: boolean) {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
index 04061ea503d..928ac786861 100644
--- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
@@ -5,6 +5,7 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getDashboardSceneFor, getGridItemKeyForPanelId, getVizPanelKeyForPanelId } from '../../utils/utils';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
+import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { ResponsiveGridItem } from './ResponsiveGridItem';
@@ -101,12 +102,38 @@ export class ResponsiveGridLayoutManager
return panels;
}
+ public hasVizPanels(): boolean {
+ for (const child of this.state.layout.state.children) {
+ if (child instanceof ResponsiveGridItem) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public addNewRow() {
+ const shouldAddRow = this.hasVizPanels();
const rowsLayout = RowsLayoutManager.createFromLayout(this);
- rowsLayout.addNewRow();
+
+ if (shouldAddRow) {
+ rowsLayout.addNewRow();
+ }
+
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
+ public addNewTab() {
+ const shouldAddTab = this.hasVizPanels();
+ const tabsLayout = TabsLayoutManager.createFromLayout(this);
+
+ if (shouldAddTab) {
+ tabsLayout.addNewTab();
+ }
+
+ getDashboardSceneFor(this).switchLayout(tabsLayout);
+ }
+
public getOptions(): OptionsPaneItemDescriptor[] {
return getEditOptions(this);
}
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 f9c5b5e43ba..0c770a333b1 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
@@ -3,9 +3,11 @@ import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
+import { getDashboardSceneFor } from '../../utils/utils';
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 { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { RowItem } from './RowItem';
@@ -62,10 +64,31 @@ export class RowsLayoutManager extends SceneObjectBase i
return panels;
}
+ public hasVizPanels(): boolean {
+ for (const row of this.state.rows) {
+ if (row.getLayout().hasVizPanels()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public addNewRow() {
this.setState({ rows: [...this.state.rows, new RowItem()] });
}
+ public addNewTab() {
+ const shouldAddTab = this.hasVizPanels();
+ const tabsLayout = TabsLayoutManager.createFromLayout(this);
+
+ if (shouldAddTab) {
+ tabsLayout.addNewTab();
+ }
+
+ getDashboardSceneFor(this).switchLayout(tabsLayout);
+ }
+
public editModeChanged(isEditing: boolean) {
this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing));
}
@@ -87,9 +110,8 @@ export class RowsLayoutManager extends SceneObjectBase i
}
public removeRow(row: RowItem) {
- this.setState({
- rows: this.state.rows.filter((r) => r !== row),
- });
+ const rows = this.state.rows.filter((r) => r !== row);
+ this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows });
}
public static createEmpty(): RowsLayoutManager {
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
new file mode 100644
index 00000000000..73ce7af6b19
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
@@ -0,0 +1,80 @@
+import { ReactNode } from 'react';
+
+import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
+import { t } from 'app/core/internationalization';
+import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
+
+import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
+import { BulkActionElement } from '../types/BulkActionElement';
+import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
+import { EditableDashboardElement } from '../types/EditableDashboardElement';
+import { LayoutParent } from '../types/LayoutParent';
+
+import { getEditOptions, renderActions } from './TabItemEditor';
+import { TabItemRenderer } from './TabItemRenderer';
+import { TabItems } from './TabItems';
+import { TabsLayoutManager } from './TabsLayoutManager';
+
+export interface TabItemState extends SceneObjectState {
+ layout: DashboardLayoutManager;
+ title?: string;
+}
+
+export class TabItem
+ extends SceneObjectBase
+ implements LayoutParent, BulkActionElement, EditableDashboardElement
+{
+ public static Component = TabItemRenderer;
+
+ protected _variableDependency = new VariableDependencyConfig(this, {
+ statePaths: ['title'],
+ });
+
+ public readonly isEditableDashboardElement = true;
+ public readonly typeName = 'Tab';
+
+ constructor(state?: Partial) {
+ super({
+ ...state,
+ title: state?.title ?? t('dashboard.tabs-layout.tab.new', 'New tab'),
+ layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(),
+ });
+ }
+
+ public getLayout(): DashboardLayoutManager {
+ return this.state.layout;
+ }
+
+ public switchLayout(layout: DashboardLayoutManager) {
+ this.setState({ layout });
+ }
+
+ public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
+ return getEditOptions(this);
+ }
+
+ public renderActions(): ReactNode {
+ return renderActions(this);
+ }
+
+ public getParentLayout(): TabsLayoutManager {
+ return sceneGraph.getAncestor(this, TabsLayoutManager);
+ }
+
+ public onDelete() {
+ const layout = sceneGraph.getAncestor(this, TabsLayoutManager);
+ layout.removeTab(this);
+ }
+
+ public createMultiSelectedElement(items: SceneObject[]): TabItems {
+ return new TabItems(items.filter((item) => item instanceof TabItem));
+ }
+
+ public onChangeTab() {
+ this.getParentLayout().changeTab(this);
+ }
+
+ public onChangeTitle(title: string) {
+ this.setState({ title });
+ }
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
new file mode 100644
index 00000000000..7d0e8c70b8c
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
@@ -0,0 +1,45 @@
+import { ReactNode, useMemo } from 'react';
+
+import { Button, Input } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
+import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
+import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
+
+import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
+
+import { TabItem } from './TabItem';
+
+export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] {
+ const tabOptions = useMemo(() => {
+ return new OptionsPaneCategoryDescriptor({
+ title: t('dashboard.tabs-layout.tab-options.title', 'Tab options'),
+ id: 'tab-options',
+ isOpenDefault: true,
+ }).addItem(
+ new OptionsPaneItemDescriptor({
+ title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'),
+ render: () => ,
+ })
+ );
+ }, [model]);
+
+ const { layout } = model.useState();
+ const layoutOptions = useLayoutCategory(layout);
+
+ return [tabOptions, layoutOptions];
+}
+
+export function renderActions(tab: TabItem): ReactNode {
+ return (
+ <>
+
+