mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 01:32:10 +08:00
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
This commit is contained in:
@ -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({
|
||||
|
Reference in New Issue
Block a user