Dashboards: Save rows and responsive grid layouts in v2 (#100035)

* save rows and responsive grid layouts in v2

* Add back accidentally removed assert

* adress feedback

* use getGridItemKeyForPanel

* Fix import

* fix another import

* Remove RowGridLayout
This commit is contained in:
Oscar Kilhed 2025-02-07 14:45:04 +01:00 committed by GitHub
parent 882b993496
commit a412394a14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 420 additions and 69 deletions

View File

@ -43,7 +43,7 @@ DashboardV2Spec: {
annotations: [...AnnotationQueryKind]
layout: GridLayoutKind
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
// Plugins only. The version of the dashboard installed together with the plugin.
@ -488,6 +488,11 @@ RowRepeatOptions: {
value: string
}
ResponsiveGridRepeatOptions: {
mode: RepeatMode
value: string
}
GridLayoutItemSpec: {
x: int
y: int
@ -524,6 +529,47 @@ GridLayoutKind: {
spec: GridLayoutSpec
}
RowsLayoutKind: {
kind: "RowsLayout"
spec: RowsLayoutSpec
}
RowsLayoutSpec: {
rows: [...RowsLayoutRowKind]
}
RowsLayoutRowKind: {
kind: "RowsLayoutRow"
spec: RowsLayoutRowSpec
}
RowsLayoutRowSpec: {
title?: string
collapsed: bool
repeat?: RowRepeatOptions
layout: GridLayoutKind | ResponsiveGridLayoutKind
}
ResponsiveGridLayoutKind: {
kind: "ResponsiveGridLayout"
spec: ResponsiveGridLayoutSpec
}
ResponsiveGridLayoutSpec: {
row: string,
col: string,
items: [...ResponsiveGridLayoutItemKind]
}
ResponsiveGridLayoutItemKind: {
kind: "ResponsiveGridLayoutItem"
spec: ResponsiveGridLayoutItemSpec
}
ResponsiveGridLayoutItemSpec: {
element: ElementReference
}
PanelSpec: {
id: number
title: string

View File

@ -30,7 +30,7 @@ export interface DashboardV2Spec {
variables: VariableKind[];
elements: Record<string, Element>;
annotations: AnnotationQueryKind[];
layout: GridLayoutKind;
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: number;
@ -710,6 +710,16 @@ export const defaultRowRepeatOptions = (): RowRepeatOptions => ({
value: "",
});
export interface ResponsiveGridRepeatOptions {
mode: "variable";
value: string;
}
export const defaultResponsiveGridRepeatOptions = (): ResponsiveGridRepeatOptions => ({
mode: RepeatMode,
value: "",
});
export interface GridLayoutItemSpec {
x: number;
y: number;
@ -752,6 +762,7 @@ export interface GridLayoutRowSpec {
y: number;
collapsed: boolean;
title: string;
// Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
elements: GridLayoutItemKind[];
repeat?: RowRepeatOptions;
}
@ -781,6 +792,86 @@ export const defaultGridLayoutKind = (): GridLayoutKind => ({
spec: defaultGridLayoutSpec(),
});
export interface RowsLayoutKind {
kind: "RowsLayout";
spec: RowsLayoutSpec;
}
export const defaultRowsLayoutKind = (): RowsLayoutKind => ({
kind: "RowsLayout",
spec: defaultRowsLayoutSpec(),
});
export interface RowsLayoutSpec {
rows: RowsLayoutRowKind[];
}
export const defaultRowsLayoutSpec = (): RowsLayoutSpec => ({
rows: [],
});
export interface RowsLayoutRowKind {
kind: "RowsLayoutRow";
spec: RowsLayoutRowSpec;
}
export const defaultRowsLayoutRowKind = (): RowsLayoutRowKind => ({
kind: "RowsLayoutRow",
spec: defaultRowsLayoutRowSpec(),
});
export interface RowsLayoutRowSpec {
title?: string;
collapsed: boolean;
repeat?: RowRepeatOptions;
layout: GridLayoutKind | ResponsiveGridLayoutKind;
}
export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({
collapsed: false,
layout: defaultGridLayoutKind(),
});
export interface ResponsiveGridLayoutKind {
kind: "ResponsiveGridLayout";
spec: ResponsiveGridLayoutSpec;
}
export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => ({
kind: "ResponsiveGridLayout",
spec: defaultResponsiveGridLayoutSpec(),
});
export interface ResponsiveGridLayoutSpec {
row: string;
col: string;
items: ResponsiveGridLayoutItemKind[];
}
export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({
row: "",
col: "",
items: [],
});
export interface ResponsiveGridLayoutItemKind {
kind: "ResponsiveGridLayoutItem";
spec: ResponsiveGridLayoutItemSpec;
}
export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemKind => ({
kind: "ResponsiveGridLayoutItem",
spec: defaultResponsiveGridLayoutItemSpec(),
});
export interface ResponsiveGridLayoutItemSpec {
element: ElementReference;
}
export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({
element: defaultElementReference(),
});
export interface PanelSpec {
id: number;
title: string;

View File

@ -35,6 +35,11 @@ export class ResponsiveGridLayoutManager
public readonly descriptor = ResponsiveGridLayoutManager.descriptor;
public static defaultCSS = {
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
};
public constructor(state: ResponsiveGridLayoutManagerState) {
super(state);
@ -118,8 +123,8 @@ export class ResponsiveGridLayoutManager
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
children: [],
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns,
autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows,
}),
});
}

View File

@ -12,6 +12,7 @@ import {
defaultDashboardV2Spec,
defaultPanelSpec,
defaultTimeSettingsSpec,
GridLayoutKind,
PanelSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types';
@ -736,7 +737,8 @@ describe('DashboardSceneSerializer', () => {
const saveAsModel = serializer.getSaveAsModel(emptyDashboard, baseOptions);
expect(saveAsModel.elements).toEqual({});
expect(saveAsModel.layout.spec.items).toEqual([]);
expect(saveAsModel.layout.kind).toBe('GridLayout');
expect((saveAsModel.layout as GridLayoutKind).spec.items).toEqual([]);
expect(saveAsModel.variables).toEqual([]);
});

View File

@ -22,6 +22,7 @@ import {
DashboardV2Spec,
DatasourceVariableKind,
GridLayoutItemSpec,
GridLayoutSpec,
GroupByVariableKind,
IntervalVariableKind,
QueryVariableKind,
@ -231,7 +232,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const panel = getPanelElement(dash, 'panel-1')!;
expect(layout.state.grid.state.children.length).toBe(3);
expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${panel.spec.id}`);
const gridLayoutItemSpec = dash.layout.spec.items[0].spec as GridLayoutItemSpec;
const gridLayoutItemSpec = (dash.layout.spec as GridLayoutSpec).items[0].spec as GridLayoutItemSpec;
expect(layout.state.grid.state.children[0].state.width).toBe(gridLayoutItemSpec.width);
expect(layout.state.grid.state.children[0].state.height).toBe(gridLayoutItemSpec.height);
expect(layout.state.grid.state.children[0].state.x).toBe(gridLayoutItemSpec.x);
@ -242,7 +243,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
// Library Panel
const libraryPanel = getLibraryPanelElement(dash, 'panel-2')!;
expect(layout.state.grid.state.children[1].state.key).toBe(`grid-item-${libraryPanel.spec.id}`);
const libraryGridLayoutItemSpec = dash.layout.spec.items[1].spec as GridLayoutItemSpec;
const libraryGridLayoutItemSpec = (dash.layout.spec as GridLayoutSpec).items[1].spec as GridLayoutItemSpec;
expect(layout.state.grid.state.children[1].state.width).toBe(libraryGridLayoutItemSpec.width);
expect(layout.state.grid.state.children[1].state.height).toBe(libraryGridLayoutItemSpec.height);
expect(layout.state.grid.state.children[1].state.x).toBe(libraryGridLayoutItemSpec.x);

View File

@ -10,6 +10,7 @@ import {
GroupByVariable,
IntervalVariable,
QueryVariable,
SceneCSSGridLayout,
SceneDataLayerControls,
SceneDataProvider,
SceneDataQuery,
@ -46,6 +47,8 @@ import {
defaultQueryVariableKind,
defaultTextVariableKind,
GridLayoutItemSpec,
GridLayoutKind,
Element,
GroupByVariableKind,
IntervalVariableKind,
LibraryPanelKind,
@ -53,6 +56,7 @@ import {
PanelQueryKind,
QueryVariableKind,
TextVariableKind,
ResponsiveGridLayoutItemKind,
} from '@grafana/schema/src/schema/dashboard/v2alpha0';
import { contextSrv } from 'app/core/core';
import {
@ -86,9 +90,14 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem';
import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';
import { getGridItemKeyForPanelId, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';
import { GRID_ROW_HEIGHT } from './const';
import { SnapshotVariable } from './custom-variables/SnapshotVariable';
@ -166,6 +175,8 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
meta.canSave = false;
}
const layoutManager: DashboardLayoutManager = createLayoutManager(dashboard);
const dashboardScene = new DashboardScene({
description: dashboard.description,
editable: dashboard.editable,
@ -178,12 +189,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
title: dashboard.title,
uid: metadata.name,
version: parseInt(metadata.resourceVersion, 10),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
isLazy: !(dashboard.preload || contextSrv.user.authenticatedBy === 'render'),
children: createSceneGridLayoutForItems(dashboard),
}),
}),
body: layoutManager,
$timeRange: new SceneTimeRange({
from: dashboard.timeSettings.from,
to: dashboard.timeSettings.to,
@ -249,12 +255,86 @@ function buildGridItem(gridItem: GridLayoutItemSpec, panel: PanelKind, yOverride
});
}
function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridItemLike[] {
const gridElements = dashboard.layout.spec.items;
function createLayoutManager(dashboard: DashboardV2Spec): DashboardLayoutManager {
if (dashboard.layout.kind === 'GridLayout') {
return new DefaultGridLayoutManager({
grid: new SceneGridLayout({
isLazy: !(dashboard.preload || contextSrv.user.authenticatedBy === 'render'),
children: createSceneGridLayoutForItems(dashboard.layout, dashboard.elements),
}),
});
} else if (dashboard.layout.kind === 'RowsLayout') {
return new RowsLayoutManager({
rows: dashboard.layout.spec.rows.map((row) => {
let layout: DashboardLayoutManager | undefined = undefined;
if (row.spec.layout.kind === 'GridLayout') {
layout = new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: createSceneGridLayoutForItems(row.spec.layout, dashboard.elements),
}),
});
}
if (row.spec.layout.kind === 'ResponsiveGridLayout') {
layout = new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
templateColumns: row.spec.layout.spec.col,
autoRows: row.spec.layout.spec.row,
children: createResponsiveGridItems(row.spec.layout.spec.items, dashboard.elements),
}),
});
}
if (!layout) {
throw new Error(`Unsupported layout kind: ${row.spec.layout.kind} in row`);
}
return new RowItem({
title: row.spec.title,
isCollapsed: row.spec.collapsed,
layout: layout,
});
}),
});
} else if (dashboard.layout.kind === 'ResponsiveGridLayout') {
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
templateColumns: dashboard.layout.spec.col,
autoRows: dashboard.layout.spec.row,
children: createResponsiveGridItems(dashboard.layout.spec.items, dashboard.elements),
}),
});
}
// @ts-ignore - this complains because we should never reach this point. If the model does not match the schema we will though.
throw new Error(`Unsupported layout type: ${dashboard.layout.kind}`);
}
function createResponsiveGridItems(
items: ResponsiveGridLayoutItemKind[],
elements: Record<string, Element>
): ResponsiveGridItem[] {
return items.map((item) => {
const panel = elements[item.spec.element.name];
if (!panel) {
throw new Error(`Panel with uid ${item.spec.element.name} not found in the dashboard elements`);
}
if (panel.kind !== 'Panel') {
throw new Error(`Unsupported element kind: ${panel.kind}`);
}
return new ResponsiveGridItem({
key: getGridItemKeyForPanelId(panel.spec.id),
body: buildVizPanel(panel),
});
});
}
function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record<string, Element>): SceneGridItemLike[] {
const gridElements = layout.spec.items;
return gridElements.map((element) => {
if (element.kind === 'GridLayoutItem') {
const panel = dashboard.elements[element.spec.element.name];
const panel = elements[element.spec.element.name];
if (!panel) {
throw new Error(`Panel with uid ${element.spec.element.name} not found in the dashboard elements`);
@ -279,7 +359,7 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
}
} else if (element.kind === 'GridLayoutRow') {
const children = element.spec.elements.map((gridElement) => {
const panel = dashboard.elements[gridElement.spec.element.name];
const panel = elements[gridElement.spec.element.name];
if (panel.kind === 'Panel') {
return buildGridItem(gridElement.spec, panel, element.spec.y + GRID_ROW_HEIGHT + gridElement.spec.y);
} else {

View File

@ -43,6 +43,10 @@ import {
DashboardCursorSync,
FieldConfig,
FieldColor,
GridLayoutKind,
RowsLayoutKind,
ResponsiveGridLayoutKind,
ResponsiveGridLayoutItemKind,
} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
@ -50,6 +54,10 @@ import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem';
import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
@ -73,22 +81,22 @@ type DeepPartial<T> = T extends object
: T;
export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): DashboardV2Spec {
const oldDash = scene.state;
const timeRange = oldDash.$timeRange!.state;
const sceneDash = scene.state;
const timeRange = sceneDash.$timeRange!.state;
const controlsState = oldDash.controls?.state;
const controlsState = sceneDash.controls?.state;
const refreshPicker = controlsState?.refreshPicker;
const dashboardSchemaV2: DeepPartial<DashboardV2Spec> = {
//dashboard settings
title: oldDash.title,
description: oldDash.description ?? '',
cursorSync: getCursorSync(oldDash),
liveNow: getLiveNow(oldDash),
preload: oldDash.preload,
editable: oldDash.editable,
links: oldDash.links,
tags: oldDash.tags,
title: sceneDash.title,
description: sceneDash.description ?? '',
cursorSync: getCursorSync(sceneDash),
liveNow: getLiveNow(sceneDash),
preload: sceneDash.preload,
editable: sceneDash.editable,
links: sceneDash.links,
tags: sceneDash.tags,
// EOF dashboard settings
// time settings
@ -107,24 +115,19 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
// EOF time settings
// variables
variables: getVariables(oldDash),
variables: getVariables(sceneDash),
// EOF variables
// elements
elements: getElements(oldDash),
elements: getElements(sceneDash),
// EOF elements
// annotations
annotations: getAnnotations(oldDash),
annotations: getAnnotations(sceneDash),
// EOF annotations
// layout
layout: {
kind: 'GridLayout',
spec: {
items: getGridLayoutItems(oldDash, isSnapshot),
},
},
layout: getLayout(sceneDash.body, isSnapshot),
// EOF layout
};
@ -141,6 +144,75 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
}
}
function getLayout(
layoutManager: DashboardLayoutManager,
isSnapshot?: boolean
): GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind {
if (layoutManager instanceof DefaultGridLayoutManager) {
return getGridLayout(layoutManager, isSnapshot);
} else if (layoutManager instanceof RowsLayoutManager) {
return {
kind: 'RowsLayout',
spec: {
rows: layoutManager.state.rows.map((row) => {
if (row.state.layout instanceof RowsLayoutManager) {
throw new Error('Nesting row layouts is not supported');
}
let layout: GridLayoutKind | ResponsiveGridLayoutKind | undefined = undefined;
if (row.state.layout instanceof DefaultGridLayoutManager) {
layout = getGridLayout(row.state.layout, isSnapshot);
} else if (row.state.layout instanceof ResponsiveGridLayoutManager) {
layout = {
kind: 'ResponsiveGridLayout',
spec: {
items: getResponsiveGridLayoutItems(row.state.layout),
col:
row.state.layout.state.layout.state.templateColumns?.toString() ??
ResponsiveGridLayoutManager.defaultCSS.templateColumns,
row:
row.state.layout.state.layout.state.autoRows?.toString() ??
ResponsiveGridLayoutManager.defaultCSS.autoRows,
},
};
}
if (!layout) {
throw new Error('Unsupported layout type');
}
return {
kind: 'RowsLayoutRow',
spec: {
title: row.state.title,
collapsed: row.state.isCollapsed ?? false,
layout: layout,
},
};
}),
},
};
} else if (layoutManager instanceof ResponsiveGridLayoutManager) {
return {
kind: 'ResponsiveGridLayout',
spec: {
items: getResponsiveGridLayoutItems(layoutManager),
col:
layoutManager.state.layout.state.templateColumns?.toString() ??
ResponsiveGridLayoutManager.defaultCSS.templateColumns,
row: layoutManager.state.layout.state.autoRows?.toString() ?? ResponsiveGridLayoutManager.defaultCSS.autoRows,
},
};
}
throw new Error('Unsupported layout type');
}
function getGridLayout(layoutManager: DefaultGridLayoutManager, isSnapshot?: boolean): GridLayoutKind {
return {
kind: 'GridLayout',
spec: {
items: getGridLayoutItems(layoutManager, isSnapshot),
},
};
}
function getCursorSync(state: DashboardSceneState) {
const cursorSync = state.$behaviors?.find((b): b is behaviors.CursorSync => b instanceof behaviors.CursorSync)?.state
.sync;
@ -160,33 +232,49 @@ function getLiveNow(state: DashboardSceneState) {
}
function getGridLayoutItems(
state: DashboardSceneState,
body: DefaultGridLayoutManager,
isSnapshot?: boolean
): Array<GridLayoutItemKind | GridLayoutRowKind> {
const body = state.body;
let elements: Array<GridLayoutItemKind | GridLayoutRowKind> = [];
if (body instanceof DefaultGridLayoutManager) {
for (const child of body.state.grid.state.children) {
if (child instanceof DashboardGridItem) {
// TODO: handle panel repeater scenario
if (child.state.variableName) {
elements = elements.concat(repeaterToLayoutItems(child, isSnapshot));
} else {
elements.push(gridItemToGridLayoutItemKind(child, isSnapshot));
}
} else if (child instanceof SceneGridRow) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
// Skip repeat rows
continue;
}
elements.push(gridRowToLayoutRowKind(child, isSnapshot));
for (const child of body.state.grid.state.children) {
if (child instanceof DashboardGridItem) {
// TODO: handle panel repeater scenario
if (child.state.variableName) {
elements = elements.concat(repeaterToLayoutItems(child, isSnapshot));
} else {
elements.push(gridItemToGridLayoutItemKind(child, isSnapshot));
}
} else if (child instanceof SceneGridRow) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
// Skip repeat rows
continue;
}
elements.push(gridRowToLayoutRowKind(child, isSnapshot));
}
}
return elements;
}
function getResponsiveGridLayoutItems(body: ResponsiveGridLayoutManager): ResponsiveGridLayoutItemKind[] {
const items: ResponsiveGridLayoutItemKind[] = [];
for (const child of body.state.layout.state.children) {
if (child instanceof ResponsiveGridItem) {
items.push({
kind: 'ResponsiveGridLayoutItem',
spec: {
element: {
kind: 'ElementReference',
name: child.state?.body?.state.key ?? 'DefaultName',
},
},
});
}
}
return items;
}
export function gridItemToGridLayoutItemKind(
gridItem: DashboardGridItem,
isSnapshot = false,
@ -729,15 +817,44 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec {
if (!('layout' in dash) || typeof dash.layout !== 'object' || dash.layout === null) {
throw new Error('Layout is not an object or is null');
}
if (!('kind' in dash.layout) || dash.layout.kind !== 'GridLayout') {
throw new Error('Layout kind is not GridLayout');
if (!('kind' in dash.layout) || dash.layout.kind === 'GridLayout') {
validateGridLayout(dash.layout);
}
if (!('spec' in dash.layout) || typeof dash.layout.spec !== 'object' || dash.layout.spec === null) {
throw new Error('Layout spec is not an object or is null');
}
if (!('items' in dash.layout.spec) || !Array.isArray(dash.layout.spec.items)) {
throw new Error('Layout spec items is not an array');
if (!('kind' in dash.layout) || dash.layout.kind === 'RowsLayout') {
validateRowsLayout(dash.layout);
}
return true;
}
function validateGridLayout(layout: unknown) {
if (typeof layout !== 'object' || layout === null) {
throw new Error('Layout is not an object or is null');
}
if (!('kind' in layout) || layout.kind !== 'GridLayout') {
throw new Error('Layout kind is not GridLayout');
}
if (!('spec' in layout) || typeof layout.spec !== 'object' || layout.spec === null) {
throw new Error('Layout spec is not an object or is null');
}
if (!('items' in layout.spec) || !Array.isArray(layout.spec.items)) {
throw new Error('Layout spec items is not an array');
}
}
function validateRowsLayout(layout: unknown) {
if (typeof layout !== 'object' || layout === null) {
throw new Error('Layout is not an object or is null');
}
if (!('kind' in layout) || layout.kind !== 'RowsLayout') {
throw new Error('Layout kind is not RowsLayout');
}
if (!('spec' in layout) || typeof layout.spec !== 'object' || layout.spec === null) {
throw new Error('Layout spec is not an object or is null');
}
if (!('rows' in layout.spec) || !Array.isArray(layout.spec.rows)) {
throw new Error('Layout spec items is not an array');
}
}

View File

@ -3,6 +3,7 @@ import {
DashboardV2Spec,
GridLayoutItemKind,
GridLayoutItemSpec,
GridLayoutKind,
GridLayoutRowSpec,
PanelKind,
VariableKind,
@ -469,8 +470,10 @@ describe('ResponseTransformers', () => {
expect(spec.annotations).toEqual([]);
// Panel
expect(spec.layout.spec.items).toHaveLength(4);
expect(spec.layout.spec.items[0].spec).toEqual({
expect(spec.layout.kind).toBe('GridLayout');
const layout = spec.layout as GridLayoutKind;
expect(layout.spec.items).toHaveLength(4);
expect(layout.spec.items[0].spec).toEqual({
element: {
kind: 'ElementReference',
name: '1',
@ -533,7 +536,7 @@ describe('ResponseTransformers', () => {
},
});
// Library Panel
expect(spec.layout.spec.items[1].spec).toEqual({
expect(layout.spec.items[1].spec).toEqual({
element: {
kind: 'ElementReference',
name: '2',
@ -555,7 +558,7 @@ describe('ResponseTransformers', () => {
},
});
const rowSpec = spec.layout.spec.items[2].spec as GridLayoutRowSpec;
const rowSpec = layout.spec.items[2].spec as GridLayoutRowSpec;
expect(rowSpec.collapsed).toBe(false);
expect(rowSpec.title).toBe('Row test title');
@ -574,7 +577,7 @@ describe('ResponseTransformers', () => {
height: 8,
});
const collapsedRowSpec = spec.layout.spec.items[3].spec as GridLayoutRowSpec;
const collapsedRowSpec = layout.spec.items[3].spec as GridLayoutRowSpec;
expect(collapsedRowSpec.collapsed).toBe(true);
expect(collapsedRowSpec.title).toBe('Collapsed row title');
expect(collapsedRowSpec.repeat).toBeUndefined();
@ -748,9 +751,11 @@ describe('ResponseTransformers', () => {
validateAnnotation(dashboard.annotations!.list![3], dashboardV2.spec.annotations[3]);
// panel
const panelKey = 'panel-1';
expect(dashboardV2.spec.elements[panelKey].kind).toBe('Panel');
const panelV2 = dashboardV2.spec.elements[panelKey] as PanelKind;
expect(panelV2.kind).toBe('Panel');
validatePanel(dashboard.panels![0], panelV2, dashboardV2.spec.layout, panelKey);
expect(dashboardV2.spec.layout.kind).toBe('GridLayout');
validatePanel(dashboard.panels![0], panelV2, dashboardV2.spec.layout as GridLayoutKind, panelKey);
// library panel
expect(dashboard.panels![1].libraryPanel).toEqual({
uid: 'uid-for-library-panel',
@ -845,7 +850,7 @@ describe('ResponseTransformers', () => {
expect(v1.filter).toEqual(v2Spec.filter);
}
function validatePanel(v1: Panel, v2: PanelKind, layoutV2: DashboardV2Spec['layout'], panelKey: string) {
function validatePanel(v1: Panel, v2: PanelKind, layoutV2: GridLayoutKind, panelKey: string) {
const { spec: v2Spec } = v2;
expect(v1.id).toBe(v2Spec.id);

View File

@ -860,6 +860,10 @@ function getPanelsV1(
let maxPanelId = 0;
if (layout.kind !== 'GridLayout') {
throw new Error('Cannot convert non-GridLayout layout to v1');
}
for (const item of layout.spec.items) {
if (item.kind === 'GridLayoutItem') {
const panel = panels[item.spec.element.name];