diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 02980a66860..e866fb4d242 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -53,7 +53,7 @@ export const Tab = React.forwardRef( if (href) { return ( -
+
( } return ( -
+
), }); + 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 ( + <> +