Dashboards: refactor transform scene layout to save model and transform save model to scene layout, schema v2 (#100322)

* Add tests

* refactor transformSaveModelToSchemaV2 and transformSceneToSaveModelV2

* move default grid serializer functions outside of class

* simplify layoutmanager descriptor

* add test for SaveModel -> Scene

* Fix lint issues

* remove auto added import

* Fix name

* Fix test typo
This commit is contained in:
Oscar Kilhed 2025-02-11 13:08:07 +01:00 committed by GitHub
parent e17fd5e8ad
commit 6ee3c71ffe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 953 additions and 562 deletions

View File

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

View File

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

View File

@ -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<RowsLayoutManagerState> 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<RowsLayoutManagerState> i
},
id: 'rows-layout',
createFromLayout: RowsLayoutManager.createFromLayout,
kind: 'RowsLayout',
};
public readonly descriptor = RowsLayoutManager.descriptor;

View File

@ -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<S = {}> 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;
}

View File

@ -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<S = {}> extends RegistryItem {
* @param saveModel
*/
createFromSaveModel?(saveModel: S): void;
/**
* Schema kind of layout
*/
kind?: DashboardV2Spec['layout']['kind'];
}

View File

@ -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<GridLayoutItemKind | GridLayoutRowKind> {
let items: Array<GridLayoutItemKind | GridLayoutRowKind> = [];
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<string, Element>): 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<D
meta.canSave = false;
}
const layoutManager: DashboardLayoutManager = createLayoutManager(dashboard);
const layoutManager: DashboardLayoutManager = layoutSerializerRegistry
.get(dashboard.layout.kind)
.serializer.deserialize(dashboard.layout, dashboard.elements, dashboard.preload);
//createLayoutManager(dashboard);
const dashboardScene = new DashboardScene({
description: dashboard.description,
@ -243,251 +218,6 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
return dashboardScene;
}
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 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 = 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;

View File

@ -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<DashboardSceneState> {
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: {

View File

@ -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<GridLayoutItemKind | GridLayoutRowKind> {
let elements: Array<GridLayoutItemKind | GridLayoutRowKind> = [];
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<string, Element> {
}, {});
}
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;