Dashboards: Tabs layout persistence (#100485)

* Tabs layout persistence

* fix lint issue

* Add tests, add tabs serializer to registry

* Fix deserialize tabs

* more tests

* tab item title optional

* change TabItemKind -> TabLayoutTabKind

* add tests for tabs serializer

* fix name in test

* Fix test after renaming tabs
pull/100808/head
Oscar Kilhed 3 months ago committed by GitHub
parent 497491846e
commit 855eadcabd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
  2. 41
      packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
  3. 5
      public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
  4. 92
      public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts
  5. 49
      public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
  6. 2
      public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts
  7. 47
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
  8. 43
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts

@ -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

@ -16,7 +16,7 @@ export interface DashboardV2Spec {
// Whether a dashboard is editable or not.
editable?: boolean;
elements: Record<string, Element>;
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;

@ -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<TabsLayoutManagerState> 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<TabsLayoutManagerState> i
},
id: 'tabs-layout',
createFromLayout: TabsLayoutManager.createFromLayout,
kind: 'TabsLayout',
};
public readonly descriptor = TabsLayoutManager.descriptor;

@ -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);
});
});

@ -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] });
}
}

@ -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<LayoutSerializerRegistryItem> =
{ 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() },
];
});

@ -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 = {

@ -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({

Loading…
Cancel
Save