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 e49511ddddd..9286c13c003 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -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 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 641cb9173cb..70262bbfdd5 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -30,7 +30,7 @@ export interface DashboardV2Spec { variables: VariableKind[]; elements: Record; 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; 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 383f4a59290..04061ea503d 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 @@ -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, }), }); } diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 0844b4c454e..27956a2bbd7 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -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([]); }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 7be999edebc..f57944b68d7 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -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); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 61ed7495a8c..6ff95bef909 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -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 { + 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 +): 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): 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 { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 928ce92733f..7a318e38b64 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -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 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 = { //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 { - const body = state.body; let elements: Array = []; - 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'); + } +} diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 1dfbd14eb04..7cf86532d4a 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -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); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 1b72d6b3c08..38f882abbc1 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -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];