diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 567df0709bf..97b268757d9 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -28,6 +28,7 @@ import { } from '../../utils/utils'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { DashboardGridItem } from './DashboardGridItem'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; @@ -45,7 +46,7 @@ export class DefaultGridLayoutManager public readonly isDashboardLayoutManager = true; - public static readonly descriptor = { + public static readonly descriptor: LayoutRegistryItem = { get name() { return t('dashboard.default-layout.name', 'Default grid'); }, @@ -54,6 +55,7 @@ export class DefaultGridLayoutManager }, id: 'default-grid', createFromLayout: DefaultGridLayoutManager.createFromLayout, + kind: 'GridLayout', }; public readonly descriptor = DefaultGridLayoutManager.descriptor; 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 928ac786861..bde10fa3374 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 @@ -7,6 +7,7 @@ import { getDashboardSceneFor, getGridItemKeyForPanelId, getVizPanelKeyForPanelI import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { ResponsiveGridItem } from './ResponsiveGridItem'; import { getEditOptions } from './ResponsiveGridLayoutManagerEditor'; @@ -23,7 +24,7 @@ export class ResponsiveGridLayoutManager public readonly isDashboardLayoutManager = true; - public static readonly descriptor = { + public static readonly descriptor: LayoutRegistryItem = { get name() { return t('dashboard.responsive-layout.name', 'Responsive grid'); }, @@ -32,6 +33,8 @@ export class ResponsiveGridLayoutManager }, id: 'responsive-grid', createFromLayout: ResponsiveGridLayoutManager.createFromLayout, + + kind: 'ResponsiveGridLayout', }; public readonly descriptor = ResponsiveGridLayoutManager.descriptor; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 0c770a333b1..fd3150af3e8 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -9,6 +9,7 @@ import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutMan import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { RowItem } from './RowItem'; import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; @@ -23,7 +24,7 @@ export class RowsLayoutManager extends SceneObjectBase i public readonly isDashboardLayoutManager = true; - public static readonly descriptor = { + public static readonly descriptor: LayoutRegistryItem = { get name() { return t('dashboard.rows-layout.name', 'Rows'); }, @@ -32,6 +33,8 @@ export class RowsLayoutManager extends SceneObjectBase i }, id: 'rows-layout', createFromLayout: RowsLayoutManager.createFromLayout, + + kind: 'RowsLayout', }; public readonly descriptor = RowsLayoutManager.descriptor; diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts index 96d0174a210..18fc2c74064 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -1,4 +1,5 @@ import { SceneObject, VizPanel } from '@grafana/scenes'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { LayoutRegistryItem } from './LayoutRegistryItem'; @@ -82,6 +83,15 @@ export interface DashboardLayoutManager extends SceneObject { cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager; } +export interface LayoutManagerSerializer { + serialize(layout: DashboardLayoutManager, isSnapshot?: boolean): DashboardV2Spec['layout']; + deserialize( + layout: DashboardV2Spec['layout'], + elements: DashboardV2Spec['elements'], + preload: boolean + ): DashboardLayoutManager; +} + export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager { return 'isDashboardLayoutManager' in obj; } diff --git a/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts b/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts index f35b4c62d3c..c22b37c2c53 100644 --- a/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts +++ b/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts @@ -1,4 +1,5 @@ import { RegistryItem } from '@grafana/data'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DashboardLayoutManager } from './DashboardLayoutManager'; @@ -17,4 +18,9 @@ export interface LayoutRegistryItem extends RegistryItem { * @param saveModel */ createFromSaveModel?(saveModel: S): void; + + /** + * Schema kind of layout + */ + kind?: DashboardV2Spec['layout']['kind']; } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts new file mode 100644 index 00000000000..b5da4a118f2 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts @@ -0,0 +1,354 @@ +import { config } from '@grafana/runtime'; +import { + SceneGridItemLike, + SceneGridLayout, + SceneGridRow, + SceneObject, + VizPanel, + VizPanelMenu, + VizPanelState, +} from '@grafana/scenes'; +import { + DashboardV2Spec, + GridLayoutItemKind, + GridLayoutKind, + GridLayoutRowKind, + RepeatOptions, + Element, + GridLayoutItemSpec, + PanelKind, + LibraryPanelKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { contextSrv } from 'app/core/core'; + +import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; +import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; +import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; +import { PanelNotices } from '../../scene/PanelNotices'; +import { AngularDeprecation } from '../../scene/angular/AngularDeprecation'; +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 { setDashboardPanelContext } from '../../scene/setDashboardPanelContext'; +import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; +import { isClonedKey } from '../../utils/clone'; +import { calculateGridItemDimensions, getVizPanelKeyForPanelId, isLibraryPanel } from '../../utils/utils'; +import { GRID_ROW_HEIGHT } from '../const'; + +import { buildVizPanel } from './utils'; + +export class DefaultGridLayoutManagerSerializer implements LayoutManagerSerializer { + serialize(layoutManager: DefaultGridLayoutManager, isSnapshot?: boolean): DashboardV2Spec['layout'] { + return { + kind: 'GridLayout', + spec: { + items: getGridLayoutItems(layoutManager, isSnapshot), + }, + }; + } + + deserialize( + layout: DashboardV2Spec['layout'], + elements: DashboardV2Spec['elements'], + preload: boolean + ): DashboardLayoutManager { + if (layout.kind !== 'GridLayout') { + throw new Error('Invalid layout kind'); + } + return new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: !(preload || contextSrv.user.authenticatedBy === 'render'), + children: createSceneGridLayoutForItems(layout, elements), + }), + }); + } +} + +function getGridLayoutItems( + body: DefaultGridLayoutManager, + isSnapshot?: boolean +): Array { + let items: Array = []; + for (const child of body.state.grid.state.children) { + if (child instanceof DashboardGridItem) { + // TODO: handle panel repeater scenario + if (child.state.variableName) { + items = items.concat(repeaterToLayoutItems(child, isSnapshot)); + } else { + items.push(gridItemToGridLayoutItemKind(child)); + } + } else if (child instanceof SceneGridRow) { + if (isClonedKey(child.state.key!) && !isSnapshot) { + // Skip repeat rows + continue; + } + items.push(gridRowToLayoutRowKind(child, isSnapshot)); + } + } + + return items; +} + +function getRowRepeat(row: SceneGridRow): RepeatOptions | undefined { + if (row.state.$behaviors) { + for (const behavior of row.state.$behaviors) { + if (behavior instanceof RowRepeaterBehavior) { + return { value: behavior.state.variableName, mode: 'variable' }; + } + } + } + return undefined; +} + +function gridRowToLayoutRowKind(row: SceneGridRow, isSnapshot = false): GridLayoutRowKind { + const children = row.state.children.map((child) => { + if (!(child instanceof DashboardGridItem)) { + throw new Error('Unsupported row child type'); + } + const y = (child.state.y ?? 0) - (row.state.y ?? 0) - GRID_ROW_HEIGHT; + return gridItemToGridLayoutItemKind(child, y); + }); + + return { + kind: 'GridLayoutRow', + spec: { + title: row.state.title, + y: row.state.y ?? 0, + collapsed: Boolean(row.state.isCollapsed), + elements: children, + repeat: getRowRepeat(row), + }, + }; +} + +function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, yOverride?: number): GridLayoutItemKind { + let elementGridItem: GridLayoutItemKind | undefined; + let x = 0, + y = 0, + width = 0, + height = 0; + + let gridItem_ = gridItem; + + if (!(gridItem_.state.body instanceof VizPanel)) { + throw new Error('DashboardGridItem body expected to be VizPanel'); + } + + // Get the grid position and size + height = (gridItem_.state.variableName ? gridItem_.state.itemHeight : gridItem_.state.height) ?? 0; + x = gridItem_.state.x ?? 0; + y = gridItem_.state.y ?? 0; + width = gridItem_.state.width ?? 0; + const repeatVar = gridItem_.state.variableName; + + // FIXME: which name should we use for the element reference, key or something else ? + const elementName = gridItem_.state.body.state.key ?? 'DefaultName'; + elementGridItem = { + kind: 'GridLayoutItem', + spec: { + x, + y: yOverride ?? y, + width: width, + height: height, + element: { + kind: 'ElementReference', + name: elementName, + }, + }, + }; + + if (repeatVar) { + const repeat: RepeatOptions = { + mode: 'variable', + value: repeatVar, + }; + + if (gridItem_.state.maxPerRow) { + repeat.maxPerRow = gridItem_.getMaxPerRow(); + } + + if (gridItem_.state.repeatDirection) { + repeat.direction = gridItem_.getRepeatDirection(); + } + + elementGridItem.spec.repeat = repeat; + } + + if (!elementGridItem) { + throw new Error('Unsupported grid item type'); + } + + return elementGridItem; +} + +function repeaterToLayoutItems(repeater: DashboardGridItem, isSnapshot = false): GridLayoutItemKind[] { + if (!isSnapshot) { + return [gridItemToGridLayoutItemKind(repeater)]; + } else { + if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { + // TODO: implement + // const { x = 0, y = 0, width: w = 0, height: h = 0 } = repeater.state; + // return [vizPanelToPanel(repeater.state.body, { x, y, w, h }, isSnapshot)]; + return []; + } + + if (repeater.state.repeatedPanels) { + const { h, w, columnCount } = calculateGridItemDimensions(repeater); + const panels = repeater.state.repeatedPanels!.map((panel, index) => { + let x = 0, + y = 0; + if (repeater.state.repeatDirection === 'v') { + x = repeater.state.x!; + y = index * h; + } else { + x = (index % columnCount) * w; + y = repeater.state.y! + Math.floor(index / columnCount) * h; + } + + const gridPos = { x, y, w, h }; + + const result: GridLayoutItemKind = { + kind: 'GridLayoutItem', + spec: { + x: gridPos.x, + y: gridPos.y, + width: gridPos.w, + height: gridPos.h, + repeat: { + mode: 'variable', + value: repeater.state.variableName!, + maxPerRow: repeater.getMaxPerRow(), + direction: repeater.state.repeatDirection, + }, + element: { + kind: 'ElementReference', + name: panel.state.key!, + }, + }, + }; + return result; + }); + + return panels; + } + return []; + } +} + +function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record): SceneGridItemLike[] { + const gridElements = layout.spec.items; + + return gridElements.map((element) => { + if (element.kind === 'GridLayoutItem') { + 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`); + } + + if (panel.kind === 'Panel') { + return buildGridItem(element.spec, panel); + } else if (panel.kind === 'LibraryPanel') { + const libraryPanel = buildLibraryPanel(panel); + + return new DashboardGridItem({ + key: `grid-item-${panel.spec.id}`, + x: element.spec.x, + y: element.spec.y, + width: element.spec.width, + height: element.spec.height, + itemHeight: element.spec.height, + body: libraryPanel, + }); + } else { + throw new Error(`Unknown element kind: ${element.kind}`); + } + } else if (element.kind === 'GridLayoutRow') { + const children = element.spec.elements.map((gridElement) => { + 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 { + throw new Error(`Unknown element kind: ${gridElement.kind}`); + } + }); + let behaviors: SceneObject[] | undefined; + if (element.spec.repeat) { + behaviors = [new RowRepeaterBehavior({ variableName: element.spec.repeat.value })]; + } + return new SceneGridRow({ + y: element.spec.y, + isCollapsed: element.spec.collapsed, + title: element.spec.title, + $behaviors: behaviors, + actions: new RowActions({}), + children, + }); + } else { + // If this has been validated by the schema we should never reach this point, which is why TS is telling us this is an error. + //@ts-expect-error + throw new Error(`Unknown layout element kind: ${element.kind}`); + } + }); +} + +function buildGridItem(gridItem: GridLayoutItemSpec, panel: PanelKind, yOverride?: number): DashboardGridItem { + const vizPanel = buildVizPanel(panel); + return new DashboardGridItem({ + key: `grid-item-${panel.spec.id}`, + x: gridItem.x, + y: yOverride ?? gridItem.y, + width: gridItem.repeat?.direction === 'h' ? 24 : gridItem.width, + height: gridItem.height, + itemHeight: gridItem.height, + body: vizPanel, + variableName: gridItem.repeat?.value, + repeatDirection: gridItem.repeat?.direction, + maxPerRow: gridItem.repeat?.maxPerRow, + }); +} + +function buildLibraryPanel(panel: LibraryPanelKind): VizPanel { + const titleItems: SceneObject[] = []; + + if (config.featureToggles.angularDeprecationUI) { + titleItems.push(new AngularDeprecation()); + } + + titleItems.push( + new VizPanelLinks({ + rawLinks: [], + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }) + ); + + titleItems.push(new PanelNotices()); + + const vizPanelState: VizPanelState = { + key: getVizPanelKeyForPanelId(panel.spec.id), + titleItems, + $behaviors: [ + new LibraryPanelBehavior({ + uid: panel.spec.libraryPanel.uid, + name: panel.spec.libraryPanel.name, + }), + ], + extendPanelContext: setDashboardPanelContext, + pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID, + title: panel.spec.title, + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }; + + if (!config.publicDashboardAccessToken) { + vizPanelState.menu = new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }); + } + + return new VizPanel(vizPanelState); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts new file mode 100644 index 00000000000..f97b7dd34c6 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts @@ -0,0 +1,65 @@ +import { SceneCSSGridLayout } from '@grafana/scenes'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; + +import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem'; +import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; +import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; +import { getGridItemKeyForPanelId } from '../../utils/utils'; + +import { buildVizPanel } from './utils'; + +export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer { + serialize(layoutManager: ResponsiveGridLayoutManager): DashboardV2Spec['layout'] { + return { + kind: 'ResponsiveGridLayout', + spec: { + col: + layoutManager.state.layout.state.templateColumns?.toString() ?? + ResponsiveGridLayoutManager.defaultCSS.templateColumns, + row: layoutManager.state.layout.state.autoRows?.toString() ?? ResponsiveGridLayoutManager.defaultCSS.autoRows, + items: layoutManager.state.layout.state.children.map((child) => { + if (!(child instanceof ResponsiveGridItem)) { + throw new Error('Expected ResponsiveGridItem'); + } + return { + kind: 'ResponsiveGridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: child.state?.body?.state.key ?? 'DefaultName', + }, + }, + }; + }), + }, + }; + } + + deserialize(layout: DashboardV2Spec['layout'], elements: DashboardV2Spec['elements']): DashboardLayoutManager { + if (layout.kind !== 'ResponsiveGridLayout') { + throw new Error('Invalid layout kind'); + } + + const children = layout.spec.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), + }); + }); + + return new ResponsiveGridLayoutManager({ + layout: new SceneCSSGridLayout({ + templateColumns: layout.spec.col, + autoRows: layout.spec.row, + children, + }), + }); + } +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts new file mode 100644 index 00000000000..d1823d6b1a7 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -0,0 +1,51 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; + +import { RowItem } from '../../scene/layout-rows/RowItem'; +import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; +import { LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; + +import { layoutSerializerRegistry } from './layoutSerializerRegistry'; +import { getLayout } from './utils'; + +export class RowsLayoutSerializer implements LayoutManagerSerializer { + serialize(layoutManager: RowsLayoutManager): DashboardV2Spec['layout'] { + return { + kind: 'RowsLayout', + spec: { + rows: layoutManager.state.rows.map((row) => { + const layout = getLayout(row.state.layout); + if (layout.kind === 'RowsLayout') { + throw new Error('Nested RowsLayout is not supported'); + } + return { + kind: 'RowsLayoutRow', + spec: { + title: row.state.title, + collapsed: row.state.isCollapsed ?? false, + layout: layout, + }, + }; + }), + }, + }; + } + + deserialize( + layout: DashboardV2Spec['layout'], + elements: DashboardV2Spec['elements'], + preload: boolean + ): RowsLayoutManager { + if (layout.kind !== 'RowsLayout') { + throw new Error('Invalid layout kind'); + } + const rows = layout.spec.rows.map((row) => { + const layout = row.spec.layout; + return new RowItem({ + title: row.spec.title, + isCollapsed: row.spec.collapsed, + layout: layoutSerializerRegistry.get(layout.kind).serializer.deserialize(layout, elements, preload), + }); + }); + return new RowsLayoutManager({ rows }); + } +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts new file mode 100644 index 00000000000..4c3ec416bf3 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts @@ -0,0 +1,20 @@ +import { Registry, RegistryItem } from '@grafana/data'; + +import { LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; + +import { DefaultGridLayoutManagerSerializer } from './DefaultGridLayoutSerializer'; +import { ResponsiveGridLayoutSerializer } from './ResponsiveGridLayoutSerializer'; +import { RowsLayoutSerializer } from './RowsLayoutSerializer'; + +interface LayoutSerializerRegistryItem extends RegistryItem { + serializer: LayoutManagerSerializer; +} + +export const layoutSerializerRegistry: Registry = + new Registry(() => { + return [ + { 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() }, + ]; + }); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts new file mode 100644 index 00000000000..70adca4f5f6 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -0,0 +1,154 @@ +import { config } from '@grafana/runtime'; +import { + SceneDataProvider, + SceneDataQuery, + SceneDataTransformer, + SceneObject, + SceneQueryRunner, + VizPanel, + VizPanelMenu, + VizPanelState, +} from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema/dist/esm/index.gen'; +import { DashboardV2Spec, PanelKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; + +import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour'; +import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; +import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; +import { PanelNotices } from '../../scene/PanelNotices'; +import { PanelTimeRange } from '../../scene/PanelTimeRange'; +import { AngularDeprecation } from '../../scene/angular/AngularDeprecation'; +import { setDashboardPanelContext } from '../../scene/setDashboardPanelContext'; +import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager'; +import { getVizPanelKeyForPanelId } from '../../utils/utils'; +import { transformMappingsToV1 } from '../transformToV1TypesUtils'; + +import { layoutSerializerRegistry } from './layoutSerializerRegistry'; + +export function buildVizPanel(panel: PanelKind): VizPanel { + const titleItems: SceneObject[] = []; + + if (config.featureToggles.angularDeprecationUI) { + titleItems.push(new AngularDeprecation()); + } + + titleItems.push( + new VizPanelLinks({ + rawLinks: panel.spec.links, + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }) + ); + + titleItems.push(new PanelNotices()); + + const queryOptions = panel.spec.data.spec.queryOptions; + const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride; + + const vizPanelState: VizPanelState = { + key: getVizPanelKeyForPanelId(panel.spec.id), + title: panel.spec.title, + description: panel.spec.description, + pluginId: panel.spec.vizConfig.kind, + options: panel.spec.vizConfig.spec.options, + fieldConfig: transformMappingsToV1(panel.spec.vizConfig.spec.fieldConfig), + pluginVersion: panel.spec.vizConfig.spec.pluginVersion, + displayMode: panel.spec.transparent ? 'transparent' : 'default', + hoverHeader: !panel.spec.title && !timeOverrideShown, + hoverHeaderOffset: 0, + $data: createPanelDataProvider(panel), + titleItems, + $behaviors: [], + extendPanelContext: setDashboardPanelContext, + // _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration + }; + + if (!config.publicDashboardAccessToken) { + vizPanelState.menu = new VizPanelMenu({ + $behaviors: [panelMenuBehavior], + }); + } + + if (queryOptions.timeFrom || queryOptions.timeShift) { + vizPanelState.$timeRange = new PanelTimeRange({ + timeFrom: queryOptions.timeFrom, + timeShift: queryOptions.timeShift, + hideTimeOverride: queryOptions.hideTimeOverride, + }); + } + + return new VizPanel(vizPanelState); +} + +export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider | undefined { + const panel = panelKind.spec; + const targets = panel.data?.spec.queries ?? []; + // Skip setting query runner for panels without queries + if (!targets?.length) { + return undefined; + } + + // Skip setting query runner for panel plugins with skipDataQuery + if (config.panels[panel.vizConfig.kind]?.skipDataQuery) { + return undefined; + } + + let dataProvider: SceneDataProvider | undefined = undefined; + const datasource = getPanelDataSource(panelKind); + + dataProvider = new SceneQueryRunner({ + datasource, + queries: targets.map(panelQueryKindToSceneQuery), + maxDataPoints: panel.data.spec.queryOptions.maxDataPoints ?? undefined, + maxDataPointsFromWidth: true, + cacheTimeout: panel.data.spec.queryOptions.cacheTimeout, + queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL, + minInterval: panel.data.spec.queryOptions.interval ?? undefined, + dataLayerFilter: { + panelId: panel.id, + }, + $behaviors: [new DashboardDatasourceBehaviour({})], + }); + + // Wrap inner data provider in a data transformer + return new SceneDataTransformer({ + $data: dataProvider, + transformations: panel.data.spec.transformations.map((transformation) => transformation.spec), + }); +} + +function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { + if (!panel.spec.data?.spec.queries?.length) { + return undefined; + } + + let datasource: DataSourceRef | undefined = undefined; + let isMixedDatasource = false; + + panel.spec.data.spec.queries.forEach((query) => { + if (!datasource) { + datasource = query.spec.datasource; + } else if (datasource.uid !== query.spec.datasource?.uid || datasource.type !== query.spec.datasource?.type) { + isMixedDatasource = true; + } + }); + + return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined; +} + +function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { + return { + refId: query.spec.refId, + datasource: query.spec.datasource, + hide: query.spec.hidden, + ...query.spec.query.spec, + }; +} + +export function getLayout(sceneState: DashboardLayoutManager): DashboardV2Spec['layout'] { + const registryItem = layoutSerializerRegistry.get(sceneState.descriptor.kind ?? ''); + if (!registryItem) { + throw new Error(`Layout serializer not found for kind: ${sceneState.descriptor.kind}`); + } + return registryItem.serializer.serialize(sceneState); +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index f57944b68d7..90033cc9856 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -14,6 +14,7 @@ import { AdHocFiltersVariable, SceneDataTransformer, SceneGridRow, + SceneGridItem, } from '@grafana/scenes'; import { AdhocVariableKind, @@ -34,6 +35,9 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; +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 { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getQueryRunnerFor } from '../utils/utils'; @@ -495,5 +499,112 @@ describe('transformSaveModelSchemaV2ToScene', () => { expect(scene.state.meta.canDelete).toBe(true); }); }); + describe('dynamic dashboard layouts', () => { + it('should build a dashboard scene with a responsive grid layout', () => { + const dashboard = cloneDeep(defaultDashboard); + dashboard.spec.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 ResponsiveGridLayoutManager; + expect(layoutManager.descriptor.kind).toBe('ResponsiveGridLayout'); + expect(layoutManager.state.layout.state.templateColumns).toBe('colString'); + expect(layoutManager.state.layout.state.autoRows).toBe('rowString'); + expect(layoutManager.state.layout.state.children.length).toBe(1); + const gridItem = layoutManager.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 = { + kind: 'RowsLayout', + spec: { + rows: [ + { + kind: 'RowsLayoutRow', + spec: { + title: 'row1', + collapsed: false, + layout: { + kind: 'ResponsiveGridLayout', + spec: { + col: 'colString', + row: 'rowString', + items: [ + { + kind: 'ResponsiveGridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: 'panel-1', + }, + }, + }, + ], + }, + }, + }, + }, + { + kind: 'RowsLayoutRow', + spec: { + title: 'row2', + collapsed: true, + layout: { + kind: 'GridLayout', + spec: { + items: [ + { + kind: 'GridLayoutItem', + spec: { + y: 0, + x: 0, + height: 10, + width: 10, + element: { + kind: 'ElementReference', + name: 'panel-2', + }, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }; + const scene = transformSaveModelSchemaV2ToScene(dashboard); + const layoutManager = scene.state.body as RowsLayoutManager; + expect(layoutManager.descriptor.kind).toBe('RowsLayout'); + expect(layoutManager.state.rows.length).toBe(2); + const row1Manager = layoutManager.state.rows[0].state.layout as ResponsiveGridLayoutManager; + expect(row1Manager.descriptor.kind).toBe('ResponsiveGridLayout'); + const row1GridItem = row1Manager.state.layout.state.children[0] as ResponsiveGridItem; + expect(row1GridItem.state.body.state.key).toBe('panel-1'); + + const row2Manager = layoutManager.state.rows[1].state.layout as DefaultGridLayoutManager; + expect(row2Manager.descriptor.kind).toBe('GridLayout'); + const row2GridItem = row2Manager.state.grid.state.children[0] as SceneGridItem; + expect(row2GridItem.state.body!.state.key).toBe('panel-2'); + }); + }); }); }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 74ed404eeb2..a8f97ad4287 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -10,15 +10,10 @@ import { GroupByVariable, IntervalVariable, QueryVariable, - SceneCSSGridLayout, SceneDataLayerControls, SceneDataProvider, SceneDataQuery, SceneDataTransformer, - SceneGridItemLike, - SceneGridLayout, - SceneGridRow, - SceneObject, SceneQueryRunner, SceneRefreshPicker, SceneTimePicker, @@ -27,9 +22,6 @@ import { SceneVariableSet, TextBoxVariable, VariableValueSelectors, - VizPanel, - VizPanelMenu, - VizPanelState, } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema/dist/esm/index.gen'; import { @@ -46,9 +38,6 @@ import { defaultIntervalVariableKind, defaultQueryVariableKind, defaultTextVariableKind, - GridLayoutItemSpec, - GridLayoutKind, - Element, GroupByVariableKind, IntervalVariableKind, LibraryPanelKind, @@ -56,9 +45,7 @@ import { PanelQueryKind, QueryVariableKind, TextVariableKind, - ResponsiveGridLayoutItemKind, } from '@grafana/schema/src/schema/dashboard/v2alpha0'; -import { contextSrv } from 'app/core/core'; import { AnnoKeyCreatedBy, AnnoKeyFolder, @@ -80,32 +67,16 @@ import { registerDashboardMacro } from '../scene/DashboardMacro'; import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior'; import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScopesFacade } from '../scene/DashboardScopesFacade'; -import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; -import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; -import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; -import { PanelNotices } from '../scene/PanelNotices'; -import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { AngularDeprecation } from '../scene/angular/AngularDeprecation'; -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 { getGridItemKeyForPanelId, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils'; +import { getIntervalsFromQueryString } from '../utils/utils'; -import { GRID_ROW_HEIGHT } from './const'; import { SnapshotVariable } from './custom-variables/SnapshotVariable'; +import { layoutSerializerRegistry } from './layoutSerializers/layoutSerializerRegistry'; import { registerPanelInteractionsReporter } from './transformSaveModelToScene'; import { transformCursorSyncV2ToV1, transformSortVariableToEnumV1, - transformMappingsToV1, transformVariableHideToEnumV1, transformVariableRefreshToEnumV1, } from './transformToV1TypesUtils'; @@ -179,7 +150,11 @@ 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 = elements[element.spec.element.name]; - - if (!panel) { - throw new Error(`Panel with uid ${element.spec.element.name} not found in the dashboard elements`); - } - - if (panel.kind === 'Panel') { - return buildGridItem(element.spec, panel); - } else if (panel.kind === 'LibraryPanel') { - const libraryPanel = buildLibraryPanel(panel); - - return new DashboardGridItem({ - key: `grid-item-${panel.spec.id}`, - x: element.spec.x, - y: element.spec.y, - width: element.spec.width, - height: element.spec.height, - itemHeight: element.spec.height, - body: libraryPanel, - }); - } else { - throw new Error(`Unknown element kind: ${element.kind}`); - } - } else if (element.kind === 'GridLayoutRow') { - const children = element.spec.elements.map((gridElement) => { - 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 { - throw new Error(`Unknown element kind: ${gridElement.kind}`); - } - }); - let behaviors: SceneObject[] | undefined; - if (element.spec.repeat) { - behaviors = [new RowRepeaterBehavior({ variableName: element.spec.repeat.value })]; - } - return new SceneGridRow({ - y: element.spec.y, - isCollapsed: element.spec.collapsed, - title: element.spec.title, - $behaviors: behaviors, - actions: new RowActions({}), - children, - }); - } else { - // If this has been validated by the schema we should never reach this point, which is why TS is telling us this is an error. - //@ts-expect-error - throw new Error(`Unknown layout element kind: ${element.kind}`); - } - }); -} - -function buildLibraryPanel(panel: LibraryPanelKind): VizPanel { - const titleItems: SceneObject[] = []; - - if (config.featureToggles.angularDeprecationUI) { - titleItems.push(new AngularDeprecation()); - } - - titleItems.push( - new VizPanelLinks({ - rawLinks: [], - menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), - }) - ); - - titleItems.push(new PanelNotices()); - - const vizPanelState: VizPanelState = { - key: getVizPanelKeyForPanelId(panel.spec.id), - titleItems, - $behaviors: [ - new LibraryPanelBehavior({ - uid: panel.spec.libraryPanel.uid, - name: panel.spec.libraryPanel.name, - }), - ], - extendPanelContext: setDashboardPanelContext, - pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID, - title: panel.spec.title, - options: {}, - fieldConfig: { - defaults: {}, - overrides: [], - }, - }; - - if (!config.publicDashboardAccessToken) { - vizPanelState.menu = new VizPanelMenu({ - $behaviors: [panelMenuBehavior], - }); - } - - return new VizPanel(vizPanelState); -} - -function buildVizPanel(panel: PanelKind): VizPanel { - const titleItems: SceneObject[] = []; - - if (config.featureToggles.angularDeprecationUI) { - titleItems.push(new AngularDeprecation()); - } - - titleItems.push( - new VizPanelLinks({ - rawLinks: panel.spec.links, - menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), - }) - ); - - titleItems.push(new PanelNotices()); - - const queryOptions = panel.spec.data.spec.queryOptions; - const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride; - - const vizPanelState: VizPanelState = { - key: getVizPanelKeyForPanelId(panel.spec.id), - title: panel.spec.title, - description: panel.spec.description, - pluginId: panel.spec.vizConfig.kind, - options: panel.spec.vizConfig.spec.options, - fieldConfig: transformMappingsToV1(panel.spec.vizConfig.spec.fieldConfig), - pluginVersion: panel.spec.vizConfig.spec.pluginVersion, - displayMode: panel.spec.transparent ? 'transparent' : 'default', - hoverHeader: !panel.spec.title && !timeOverrideShown, - hoverHeaderOffset: 0, - $data: createPanelDataProvider(panel), - titleItems, - $behaviors: [], - extendPanelContext: setDashboardPanelContext, - // _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration - }; - - if (!config.publicDashboardAccessToken) { - vizPanelState.menu = new VizPanelMenu({ - $behaviors: [panelMenuBehavior], - }); - } - - if (queryOptions.timeFrom || queryOptions.timeShift) { - vizPanelState.$timeRange = new PanelTimeRange({ - timeFrom: queryOptions.timeFrom, - timeShift: queryOptions.timeShift, - hideTimeOverride: queryOptions.hideTimeOverride, - }); - } - - return new VizPanel(vizPanelState); -} - function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { if (!panel.spec.data?.spec.queries?.length) { return undefined; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index c87a4a0a687..39ff7241b11 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -9,6 +9,7 @@ import { GroupByVariable, IntervalVariable, QueryVariable, + SceneCSSGridLayout, SceneGridLayout, SceneGridRow, SceneRefreshPicker, @@ -24,6 +25,10 @@ import { VariableSort as VariableSortV1, } from '@grafana/schema/dist/esm/index.gen'; +import { + ResponsiveGridLayoutSpec, + RowsLayoutSpec, +} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -33,6 +38,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; 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 { RowItem } from '../scene/layout-rows/RowItem'; +import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; +import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; @@ -365,6 +375,158 @@ describe('transformSceneToSaveModelSchemaV2', () => { }); }); +function getMinimalSceneState(body: DashboardLayoutManager): Partial { + return { + id: 1, + title: 'Test Dashboard', + description: 'Test Description', + preload: true, + tags: ['tag1', 'tag2'], + uid: 'test-uid', + version: 1, + + controls: new DashboardControls({ + refreshPicker: new SceneRefreshPicker({ + refresh: '5s', + intervals: ['5s', '10s', '30s'], + autoEnabled: true, + autoMinInterval: '5s', + autoValue: '5s', + isOnCanvas: true, + primary: true, + withText: true, + minRefreshInterval: '5s', + }), + timePicker: new SceneTimePicker({ + isOnCanvas: true, + hidePicker: true, + }), + }), + + $timeRange: new SceneTimeRange({ + timeZone: 'UTC', + from: 'now-1h', + to: 'now', + weekStart: 'monday', + fiscalYearStartMonth: 1, + UNSAFE_nowDelay: '1m', + refreshOnActivate: { + afterMs: 10, + percent: 0.1, + }, + }), + + body, + }; +} + +describe('dynamic layouts', () => { + it('should transform scene with rows layout with default grids in rows to save model schema v2', () => { + const scene = setupDashboardScene( + getMinimalSceneState( + new RowsLayoutManager({ + rows: [ + new RowItem({ + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + y: 0, + height: 10, + body: new VizPanel({}), + }), + ], + }), + }), + }), + ], + }) + ) + ); + + const result = transformSceneToSaveModelSchemaV2(scene); + expect(result.layout.kind).toBe('RowsLayout'); + const rowsLayout = result.layout.spec as RowsLayoutSpec; + expect(rowsLayout.rows.length).toBe(1); + expect(rowsLayout.rows[0].kind).toBe('RowsLayoutRow'); + expect(rowsLayout.rows[0].spec.layout.kind).toBe('GridLayout'); + }); + + it('should transform scene with rows layout with multiple rows with different grids to save model schema v2', () => { + const scene = setupDashboardScene( + getMinimalSceneState( + new RowsLayoutManager({ + rows: [ + new RowItem({ + layout: new ResponsiveGridLayoutManager({ + layout: new SceneCSSGridLayout({ + children: [ + new ResponsiveGridItem({ + body: new VizPanel({}), + }), + ], + }), + }), + }), + new RowItem({ + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + y: 0, + height: 10, + body: new VizPanel({}), + }), + ], + }), + }), + }), + ], + }) + ) + ); + + const result = transformSceneToSaveModelSchemaV2(scene); + expect(result.layout.kind).toBe('RowsLayout'); + const rowsLayout = result.layout.spec as RowsLayoutSpec; + 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'); + + expect(rowsLayout.rows[1].spec.layout.kind).toBe('GridLayout'); + expect(rowsLayout.rows[1].spec.layout.spec.items[0].kind).toBe('GridLayoutItem'); + }); + + it('should transform scene with responsive grid layout to schema v2', () => { + const scene = setupDashboardScene( + getMinimalSceneState( + new ResponsiveGridLayoutManager({ + layout: new SceneCSSGridLayout({ + autoRows: 'rowString', + templateColumns: 'colString', + children: [ + new ResponsiveGridItem({ + body: new VizPanel({}), + }), + new ResponsiveGridItem({ + body: new VizPanel({}), + }), + ], + }), + }) + ) + ); + const result = transformSceneToSaveModelSchemaV2(scene); + expect(result.layout.kind).toBe('ResponsiveGridLayout'); + const respGridLayout = result.layout.spec as ResponsiveGridLayoutSpec; + expect(respGridLayout.col).toBe('colString'); + expect(respGridLayout.row).toBe('rowString'); + expect(respGridLayout.items.length).toBe(2); + expect(respGridLayout.items[0].kind).toBe('ResponsiveGridLayoutItem'); + }); +}); + const annotationLayer1 = new DashboardAnnotationsDataLayer({ key: 'layer1', query: { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 7a318e38b64..59b44050d81 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -7,7 +7,6 @@ import { dataLayers, SceneDataQuery, SceneDataTransformer, - SceneGridRow, SceneVariableSet, VizPanel, } from '@grafana/scenes'; @@ -24,7 +23,6 @@ import { DataTransformerConfig, PanelQuerySpec, DataQueryKind, - GridLayoutItemKind, QueryOptionsSpec, QueryVariableKind, TextVariableKind, @@ -38,27 +36,13 @@ import { DataLink, LibraryPanelKind, Element, - RepeatOptions, - GridLayoutRowKind, 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'; 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 { getLibraryPanelBehavior, @@ -66,10 +50,9 @@ import { getQueryRunnerFor, getVizPanelKeyForPanelId, isLibraryPanel, - calculateGridItemDimensions, } from '../utils/utils'; -import { GRID_ROW_HEIGHT } from './const'; +import { getLayout } from './layoutSerializers/utils'; import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; import { colorIdEnumToColorIdV2, transformCursorSynctoEnum } from './transformToV2TypesUtils'; @@ -127,7 +110,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps // EOF annotations // layout - layout: getLayout(sceneDash.body, isSnapshot), + layout: getLayout(sceneDash.body), // EOF layout }; @@ -144,75 +127,6 @@ 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; @@ -231,146 +145,6 @@ function getLiveNow(state: DashboardSceneState) { return Boolean(liveNow); } -function getGridLayoutItems( - body: DefaultGridLayoutManager, - isSnapshot?: boolean -): Array { - let elements: Array = []; - 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, - yOverride?: number -): GridLayoutItemKind { - let elementGridItem: GridLayoutItemKind | undefined; - let x = 0, - y = 0, - width = 0, - height = 0; - - let gridItem_ = gridItem; - - if (!(gridItem_.state.body instanceof VizPanel)) { - throw new Error('DashboardGridItem body expected to be VizPanel'); - } - - // Get the grid position and size - height = (gridItem_.state.variableName ? gridItem_.state.itemHeight : gridItem_.state.height) ?? 0; - x = gridItem_.state.x ?? 0; - y = gridItem_.state.y ?? 0; - width = gridItem_.state.width ?? 0; - const repeatVar = gridItem_.state.variableName; - - // FIXME: which name should we use for the element reference, key or something else ? - const elementName = gridItem_.state.body.state.key ?? 'DefaultName'; - elementGridItem = { - kind: 'GridLayoutItem', - spec: { - x, - y: yOverride ?? y, - width: width, - height: height, - element: { - kind: 'ElementReference', - name: elementName, - }, - }, - }; - - if (repeatVar) { - const repeat: RepeatOptions = { - mode: 'variable', - value: repeatVar, - }; - - if (gridItem_.state.maxPerRow) { - repeat.maxPerRow = gridItem_.getMaxPerRow(); - } - - if (gridItem_.state.repeatDirection) { - repeat.direction = gridItem_.getRepeatDirection(); - } - - elementGridItem.spec.repeat = repeat; - } - - if (!elementGridItem) { - throw new Error('Unsupported grid item type'); - } - - return elementGridItem; -} - -function getRowRepeat(row: SceneGridRow): RepeatOptions | undefined { - if (row.state.$behaviors) { - for (const behavior of row.state.$behaviors) { - if (behavior instanceof RowRepeaterBehavior) { - return { value: behavior.state.variableName, mode: 'variable' }; - } - } - } - return undefined; -} - -function gridRowToLayoutRowKind(row: SceneGridRow, isSnapshot = false): GridLayoutRowKind { - const children = row.state.children.map((child) => { - if (!(child instanceof DashboardGridItem)) { - throw new Error('Unsupported row child type'); - } - const y = (child.state.y ?? 0) - (row.state.y ?? 0) - GRID_ROW_HEIGHT; - return gridItemToGridLayoutItemKind(child, isSnapshot, y); - }); - - return { - kind: 'GridLayoutRow', - spec: { - title: row.state.title, - y: row.state.y ?? 0, - collapsed: Boolean(row.state.isCollapsed), - elements: children, - repeat: getRowRepeat(row), - }, - }; -} - function getElements(state: DashboardSceneState) { const panels = state.body.getVizPanels() ?? []; @@ -577,60 +351,6 @@ function createElements(panels: Element[]): Record { }, {}); } -function repeaterToLayoutItems(repeater: DashboardGridItem, isSnapshot = false): GridLayoutItemKind[] { - if (!isSnapshot) { - return [gridItemToGridLayoutItemKind(repeater)]; - } else { - if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { - // TODO: implement - // const { x = 0, y = 0, width: w = 0, height: h = 0 } = repeater.state; - // return [vizPanelToPanel(repeater.state.body, { x, y, w, h }, isSnapshot)]; - return []; - } - - if (repeater.state.repeatedPanels) { - const { h, w, columnCount } = calculateGridItemDimensions(repeater); - const panels = repeater.state.repeatedPanels!.map((panel, index) => { - let x = 0, - y = 0; - if (repeater.state.repeatDirection === 'v') { - x = repeater.state.x!; - y = index * h; - } else { - x = (index % columnCount) * w; - y = repeater.state.y! + Math.floor(index / columnCount) * h; - } - - const gridPos = { x, y, w, h }; - - const result: GridLayoutItemKind = { - kind: 'GridLayoutItem', - spec: { - x: gridPos.x, - y: gridPos.y, - width: gridPos.w, - height: gridPos.h, - repeat: { - mode: 'variable', - value: repeater.state.variableName!, - maxPerRow: repeater.getMaxPerRow(), - direction: repeater.state.repeatDirection, - }, - element: { - kind: 'ElementReference', - name: panel.state.key!, - }, - }, - }; - return result; - }); - - return panels; - } - return []; - } -} - function getVariables(oldDash: DashboardSceneState) { const variablesSet = oldDash.$variables;