diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index f9763bb219b..a8a7511443d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -22,7 +22,7 @@ DashboardV2Spec: { elements: [ElementReference.name]: Element - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind // Links with references to other dashboards or external websites. links: [...DashboardLink] @@ -553,7 +553,7 @@ RowsLayoutRowSpec: { title?: string collapsed: bool repeat?: RowRepeatOptions - layout: GridLayoutKind | ResponsiveGridLayoutKind + layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind } ResponsiveGridLayoutKind: { @@ -576,6 +576,25 @@ ResponsiveGridLayoutItemSpec: { element: ElementReference } +TabsLayoutKind: { + kind: "TabsLayout" + spec: TabsLayoutSpec +} + +TabsLayoutSpec: { + tabs: [...TabsLayoutTabKind] +} + +TabsLayoutTabKind: { + kind: "TabsLayoutTab" + spec: TabsLayoutTabSpec +} + +TabsLayoutTabSpec: { + title?: string + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind +} + PanelSpec: { id: number title: string diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index 1ad02256fe8..a8fe2500f3a 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -16,7 +16,7 @@ export interface DashboardV2Spec { // Whether a dashboard is editable or not. editable?: boolean; elements: Record; - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind; + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind; // Links with references to other dashboards or external websites. links: DashboardLink[]; // When set to true, the dashboard will redraw panels at an interval matching the pixel width. @@ -825,7 +825,7 @@ export interface RowsLayoutRowSpec { title?: string; collapsed: boolean; repeat?: RowRepeatOptions; - layout: GridLayoutKind | ResponsiveGridLayoutKind; + layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind; } export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({ @@ -873,6 +873,43 @@ export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemS element: defaultElementReference(), }); +export interface TabsLayoutKind { + kind: "TabsLayout"; + spec: TabsLayoutSpec; +} + +export const defaultTabsLayoutKind = (): TabsLayoutKind => ({ + kind: "TabsLayout", + spec: defaultTabsLayoutSpec(), +}); + +export interface TabsLayoutSpec { + tabs: TabsLayoutTabKind[]; +} + +export const defaultTabsLayoutSpec = (): TabsLayoutSpec => ({ + tabs: [], +}); + +export interface TabsLayoutTabKind { + kind: "TabsLayoutTab"; + spec: TabsLayoutTabSpec; +} + +export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({ + kind: "TabsLayoutTab", + spec: defaultTabsLayoutTabSpec(), +}); + +export interface TabsLayoutTabSpec { + title?: string; + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind; +} + +export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({ + layout: defaultGridLayoutKind(), +}); + export interface PanelSpec { id: number; title: string; diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 1bece8ad274..62279b315c1 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -2,6 +2,7 @@ import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; import { t } from 'app/core/internationalization'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { TabItem } from './TabItem'; import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer'; @@ -16,7 +17,7 @@ export class TabsLayoutManager extends SceneObjectBase i public readonly isDashboardLayoutManager = true; - public static readonly descriptor = { + public static readonly descriptor: LayoutRegistryItem = { get name() { return t('dashboard.tabs-layout.name', 'Tabs'); }, @@ -25,6 +26,8 @@ export class TabsLayoutManager extends SceneObjectBase i }, id: 'tabs-layout', createFromLayout: TabsLayoutManager.createFromLayout, + + kind: 'TabsLayout', }; public readonly descriptor = TabsLayoutManager.descriptor; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts new file mode 100644 index 00000000000..e1e0fc7901e --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts @@ -0,0 +1,92 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; + +import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; +import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; +import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; + +import { TabsLayoutSerializer } from './TabsLayoutSerializer'; + +describe('deserialization', () => { + it('should deserialize tabs layout with row child', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'TabsLayout', + spec: { + tabs: [{ kind: 'TabsLayoutTab', spec: { title: 'Tab 1', layout: { kind: 'RowsLayout', spec: { rows: [] } } } }], + }, + }; + const serializer = new TabsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(TabsLayoutManager); + expect(deserialized.state.tabs[0].state.layout).toBeInstanceOf(RowsLayoutManager); + }); + + it('should deserialize tabs layout with responsive grid child', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'TabsLayout', + spec: { + tabs: [ + { + kind: 'TabsLayoutTab', + spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } }, + }, + ], + }, + }; + const serializer = new TabsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(TabsLayoutManager); + expect(deserialized.state.tabs[0].state.layout).toBeInstanceOf(ResponsiveGridLayoutManager); + }); + + it('should deserialize tabs layout with default grid child', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'TabsLayout', + spec: { + tabs: [ + { + kind: 'TabsLayoutTab', + spec: { title: 'Tab 1', layout: { kind: 'GridLayout', spec: { items: [] } } }, + }, + ], + }, + }; + const serializer = new TabsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(TabsLayoutManager); + expect(deserialized.state.tabs[0].state.layout).toBeInstanceOf(DefaultGridLayoutManager); + }); + + it('should handle multiple tabs', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'TabsLayout', + spec: { + tabs: [ + { + kind: 'TabsLayoutTab', + spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } }, + }, + { kind: 'TabsLayoutTab', spec: { title: 'Tab 2', layout: { kind: 'GridLayout', spec: { items: [] } } } }, + ], + }, + }; + const serializer = new TabsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(TabsLayoutManager); + expect(deserialized.state.tabs[0].state.layout).toBeInstanceOf(ResponsiveGridLayoutManager); + expect(deserialized.state.tabs[1].state.layout).toBeInstanceOf(DefaultGridLayoutManager); + }); + + it('should handle 0 tabs', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'TabsLayout', + spec: { + tabs: [], + }, + }; + const serializer = new TabsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(TabsLayoutManager); + expect(deserialized.state.tabs).toHaveLength(0); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts new file mode 100644 index 00000000000..d6eb10803a1 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -0,0 +1,49 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; + +import { TabItem } from '../../scene/layout-tabs/TabItem'; +import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; +import { LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; + +import { layoutSerializerRegistry } from './layoutSerializerRegistry'; +import { getLayout } from './utils'; + +export class TabsLayoutSerializer implements LayoutManagerSerializer { + serialize(layoutManager: TabsLayoutManager): DashboardV2Spec['layout'] { + return { + kind: 'TabsLayout', + spec: { + tabs: layoutManager.state.tabs.map((tab) => { + const layout = getLayout(tab.state.layout); + if (layout.kind === 'TabsLayout') { + throw new Error('Nested TabsLayout is not supported'); + } + return { + kind: 'TabsLayoutTab', + spec: { + title: tab.state.title, + layout: layout, + }, + }; + }), + }, + }; + } + + deserialize( + layout: DashboardV2Spec['layout'], + elements: DashboardV2Spec['elements'], + preload: boolean + ): TabsLayoutManager { + if (layout.kind !== 'TabsLayout') { + throw new Error('Invalid layout kind'); + } + const tabs = layout.spec.tabs.map((tab) => { + const layout = tab.spec.layout; + return new TabItem({ + title: tab.spec.title, + layout: layoutSerializerRegistry.get(layout.kind).serializer.deserialize(layout, elements, preload), + }); + }); + return new TabsLayoutManager({ tabs, currentTab: tabs[0] }); + } +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts index 4c3ec416bf3..d39e0b6bd5a 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts @@ -5,6 +5,7 @@ import { LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManage import { DefaultGridLayoutManagerSerializer } from './DefaultGridLayoutSerializer'; import { ResponsiveGridLayoutSerializer } from './ResponsiveGridLayoutSerializer'; import { RowsLayoutSerializer } from './RowsLayoutSerializer'; +import { TabsLayoutSerializer } from './TabsLayoutSerializer'; interface LayoutSerializerRegistryItem extends RegistryItem { serializer: LayoutManagerSerializer; @@ -16,5 +17,6 @@ export const layoutSerializerRegistry: Registry = { id: 'GridLayout', name: 'Grid Layout', serializer: new DefaultGridLayoutManagerSerializer() }, { id: 'ResponsiveGridLayout', name: 'Responsive Grid Layout', serializer: new ResponsiveGridLayoutSerializer() }, { id: 'RowsLayout', name: 'Rows Layout', serializer: new RowsLayoutSerializer() }, + { id: 'TabsLayout', name: 'Tabs Layout', serializer: new TabsLayoutSerializer() }, ]; }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 108a1e1d159..cf8308c90ca 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -38,6 +38,7 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem'; import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; +import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getQueryRunnerFor } from '../utils/utils'; @@ -533,6 +534,52 @@ describe('transformSaveModelSchemaV2ToScene', () => { expect(gridItem.state.body.state.key).toBe('panel-1'); }); + it('should build a dashboard scene with a tabs layout', () => { + const dashboard = cloneDeep(defaultDashboard); + dashboard.spec.layout = { + kind: 'TabsLayout', + spec: { + tabs: [ + { + kind: 'TabsLayoutTab', + spec: { + title: 'tab1', + layout: { + kind: 'ResponsiveGridLayout', + spec: { + col: 'colString', + row: 'rowString', + items: [ + { + kind: 'ResponsiveGridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: 'panel-1', + }, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }; + const scene = transformSaveModelSchemaV2ToScene(dashboard); + const layoutManager = scene.state.body as TabsLayoutManager; + expect(layoutManager.descriptor.kind).toBe('TabsLayout'); + expect(layoutManager.state.tabs.length).toBe(1); + expect(layoutManager.state.tabs[0].state.title).toBe('tab1'); + const gridLayoutManager = layoutManager.state.tabs[0].state.layout as ResponsiveGridLayoutManager; + expect(gridLayoutManager.state.layout.state.templateColumns).toBe('colString'); + expect(gridLayoutManager.state.layout.state.autoRows).toBe('rowString'); + expect(gridLayoutManager.state.layout.state.children.length).toBe(1); + const gridItem = gridLayoutManager.state.layout.state.children[0] as ResponsiveGridItem; + expect(gridItem.state.body.state.key).toBe('panel-1'); + }); + it('should build a dashboard scene with rows layout', () => { const dashboard = cloneDeep(defaultDashboard); dashboard.spec.layout = { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 39ff7241b11..4ff7c331b78 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -26,8 +26,10 @@ import { } from '@grafana/schema/dist/esm/index.gen'; import { + GridLayoutSpec, ResponsiveGridLayoutSpec, RowsLayoutSpec, + TabsLayoutSpec, } from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; @@ -42,6 +44,8 @@ import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGr import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { RowItem } from '../scene/layout-rows/RowItem'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; +import { TabItem } from '../scene/layout-tabs/TabItem'; +import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; @@ -492,10 +496,12 @@ describe('dynamic layouts', () => { expect(rowsLayout.rows.length).toBe(2); expect(rowsLayout.rows[0].kind).toBe('RowsLayoutRow'); expect(rowsLayout.rows[0].spec.layout.kind).toBe('ResponsiveGridLayout'); - expect(rowsLayout.rows[0].spec.layout.spec.items[0].kind).toBe('ResponsiveGridLayoutItem'); + const layout1 = rowsLayout.rows[0].spec.layout.spec as ResponsiveGridLayoutSpec; + expect(layout1.items[0].kind).toBe('ResponsiveGridLayoutItem'); expect(rowsLayout.rows[1].spec.layout.kind).toBe('GridLayout'); - expect(rowsLayout.rows[1].spec.layout.spec.items[0].kind).toBe('GridLayoutItem'); + const layout2 = rowsLayout.rows[1].spec.layout.spec as GridLayoutSpec; + expect(layout2.items[0].kind).toBe('GridLayoutItem'); }); it('should transform scene with responsive grid layout to schema v2', () => { @@ -525,6 +531,39 @@ describe('dynamic layouts', () => { expect(respGridLayout.items.length).toBe(2); expect(respGridLayout.items[0].kind).toBe('ResponsiveGridLayoutItem'); }); + + it('should transform scene with tabs layout to schema v2', () => { + const tabs = [ + new TabItem({ + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + y: 0, + height: 10, + body: new VizPanel({}), + }), + ], + }), + }), + }), + ]; + + const scene = setupDashboardScene( + getMinimalSceneState( + new TabsLayoutManager({ + currentTab: tabs[0], + tabs, + }) + ) + ); + const result = transformSceneToSaveModelSchemaV2(scene); + expect(result.layout.kind).toBe('TabsLayout'); + const tabsLayout = result.layout.spec as TabsLayoutSpec; + expect(tabsLayout.tabs.length).toBe(1); + expect(tabsLayout.tabs[0].kind).toBe('TabsLayoutTab'); + expect(tabsLayout.tabs[0].spec.layout.kind).toBe('GridLayout'); + }); }); const annotationLayer1 = new DashboardAnnotationsDataLayer({