diff --git a/.betterer.results b/.betterer.results index 11c1a73c70b..71a880f3a9a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3018,7 +3018,10 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"] ], "public/app/features/dashboard-scene/utils/test-utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx index 6a58da61b87..ae74628a711 100644 --- a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.test.tsx @@ -1,19 +1,8 @@ import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; -import { - EmbeddedScene, - SceneGridLayout, - SceneGridRow, - SceneTimeRange, - SceneVariableSet, - TestVariable, - VizPanel, -} from '@grafana/scenes'; -import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { SceneGridLayout } from '@grafana/scenes'; -import { activateFullSceneTree } from '../utils/test-utils'; - -import { PanelRepeaterGridItem, RepeatDirection } from './PanelRepeaterGridItem'; +import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils'; setPluginImportUtils({ importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), @@ -22,7 +11,7 @@ setPluginImportUtils({ describe('PanelRepeaterGridItem', () => { it('Given scene with variable with 2 values', async () => { - const { scene, repeater } = buildScene({ variableQueryTime: 0 }); + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0 }); activateFullSceneTree(scene); @@ -38,7 +27,7 @@ describe('PanelRepeaterGridItem', () => { }); it('Should wait for variable to load', async () => { - const { scene, repeater } = buildScene({ variableQueryTime: 1 }); + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 1 }); activateFullSceneTree(scene); @@ -50,7 +39,7 @@ describe('PanelRepeaterGridItem', () => { }); it('Should adjust container height to fit panels direction is horizontal', async () => { - const { scene, repeater } = buildScene({ variableQueryTime: 0, maxPerRow: 2, itemHeight: 10 }); + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, maxPerRow: 2, itemHeight: 10 }); const layoutForceRender = jest.fn(); (scene.state.body as SceneGridLayout).forceRender = layoutForceRender; @@ -64,7 +53,7 @@ describe('PanelRepeaterGridItem', () => { }); it('Should adjust container height to fit panels when direction is vertical', async () => { - const { scene, repeater } = buildScene({ variableQueryTime: 0, itemHeight: 10, repeatDirection: 'v' }); + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, itemHeight: 10, repeatDirection: 'v' }); activateFullSceneTree(scene); @@ -73,7 +62,7 @@ describe('PanelRepeaterGridItem', () => { }); it('Should adjust itemHeight when container is resized, direction horizontal', async () => { - const { scene, repeater } = buildScene({ + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, itemHeight: 10, repeatDirection: 'h', @@ -92,7 +81,7 @@ describe('PanelRepeaterGridItem', () => { }); it('Should adjust itemHeight when container is resized, direction vertical', async () => { - const { scene, repeater } = buildScene({ + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, itemHeight: 10, repeatDirection: 'v', @@ -110,7 +99,7 @@ describe('PanelRepeaterGridItem', () => { }); it('When updating variable should update repeats', async () => { - const { scene, repeater, variable } = buildScene({ variableQueryTime: 0 }); + const { scene, repeater, variable } = buildPanelRepeaterScene({ variableQueryTime: 0 }); activateFullSceneTree(scene); @@ -119,57 +108,3 @@ describe('PanelRepeaterGridItem', () => { expect(repeater.state.repeatedPanels?.length).toBe(2); }); }); - -interface SceneOptions { - variableQueryTime: number; - maxPerRow?: number; - itemHeight?: number; - repeatDirection?: RepeatDirection; -} - -function buildScene(options: SceneOptions) { - const repeater = new PanelRepeaterGridItem({ - variableName: 'server', - repeatedPanels: [], - repeatDirection: options.repeatDirection, - maxPerRow: options.maxPerRow, - itemHeight: options.itemHeight, - source: new VizPanel({ - title: 'Panel $server', - pluginId: 'timeseries', - }), - }); - - const variable = new TestVariable({ - name: 'server', - query: 'A.*', - value: ALL_VARIABLE_VALUE, - text: ALL_VARIABLE_TEXT, - isMulti: true, - includeAll: true, - delayMs: options.variableQueryTime, - optionsToReturn: [ - { label: 'A', value: '1' }, - { label: 'B', value: '2' }, - { label: 'C', value: '3' }, - { label: 'D', value: '4' }, - { label: 'E', value: '5' }, - ], - }); - - const scene = new EmbeddedScene({ - $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), - $variables: new SceneVariableSet({ - variables: [variable], - }), - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - children: [repeater], - }), - ], - }), - }); - - return { scene, repeater, variable }; -} diff --git a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx index ed553c14338..fc0140997db 100644 --- a/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/PanelRepeaterGridItem.tsx @@ -153,7 +153,7 @@ export class PanelRepeaterGridItem extends SceneObjectBase [reduceTransformRegistryItem]); setPluginImportUtils({ @@ -597,6 +603,107 @@ describe('transformSceneToSaveModel', () => { expect(snapshot.panels?.[4].collapsed).toEqual(true); }); + describe('repeats', () => { + it('handles repeated panels', async () => { + const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, numberOfOptions: 2 }); + + activateFullSceneTree(scene); + + expect(repeater.state.repeatedPanels?.length).toBe(2); + const result = panelRepeaterToPanels(repeater, true); + + expect(result).toHaveLength(2); + + // @ts-expect-error + expect(result[0].scopedVars).toEqual({ + server: { + text: 'A', + value: '1', + }, + }); + // @ts-expect-error + expect(result[1].scopedVars).toEqual({ + server: { + text: 'B', + value: '2', + }, + }); + + expect(result[0].title).toEqual('Panel $server'); + expect(result[1].title).toEqual('Panel $server'); + }); + + it('handles row repeats ', () => { + const { scene, row } = buildPanelRepeaterScene({ + variableQueryTime: 0, + numberOfOptions: 2, + useRowRepeater: true, + usePanelRepeater: false, + }); + + activateFullSceneTree(scene); + + let panels: Panel[] = []; + gridRowToSaveModel(row, panels, true); + + expect(panels).toHaveLength(2); + expect(panels[0].repeat).toBe('handler'); + + // @ts-expect-error + expect(panels[0].scopedVars).toEqual({ + handler: { + text: 'AA', + value: '11', + }, + }); + + expect(panels[1].title).toEqual('Panel $server'); + expect(panels[1].gridPos).toEqual({ x: 0, y: 0, w: 10, h: 10 }); + }); + + it('handles row repeats with panel repeater', () => { + const { scene, row } = buildPanelRepeaterScene({ + variableQueryTime: 0, + numberOfOptions: 2, + useRowRepeater: true, + usePanelRepeater: true, + }); + + activateFullSceneTree(scene); + + let panels: Panel[] = []; + gridRowToSaveModel(row, panels, true); + + expect(panels[0].repeat).toBe('handler'); + + // @ts-expect-error + expect(panels[0].scopedVars).toEqual({ + handler: { + text: 'AA', + value: '11', + }, + }); + + // @ts-expect-error + expect(panels[1].scopedVars).toEqual({ + server: { + text: 'A', + value: '1', + }, + }); + // @ts-expect-error + expect(panels[2].scopedVars).toEqual({ + server: { + text: 'B', + value: '2', + }, + }); + + expect(panels[1].title).toEqual('Panel $server'); + expect(panels[2].title).toEqual('Panel $server'); + }); + }); + describe('trimDashboardForSnapshot', () => { let snapshot: Dashboard = {} as Dashboard; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 1cbd1a78ac0..26d9cd660cd 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -1,4 +1,4 @@ -import { isEmptyObject, TimeRange } from '@grafana/data'; +import { isEmptyObject, ScopedVars, TimeRange } from '@grafana/data'; import { SceneDataLayers, SceneGridItem, @@ -10,6 +10,7 @@ import { SceneDataTransformer, SceneVariableSet, AdHocFilterSet, + LocalValueVariable, } from '@grafana/scenes'; import { AnnotationQuery, @@ -45,7 +46,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa const data = state.$data; const variablesSet = state.$variables; const body = state.body; - const panels: Panel[] = []; + let panels: Panel[] = []; let variables: VariableModel[] = []; @@ -55,9 +56,13 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa panels.push(gridItemToPanel(child, isSnapshot)); } + if (child instanceof PanelRepeaterGridItem) { + panels = panels.concat(panelRepeaterToPanels(child, isSnapshot)); + } + if (child instanceof SceneGridRow) { - // Skip repeat clones - if (child.state.key!.indexOf('-clone-') > 0) { + // Skip repeat clones or when generating a snapshot + if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { continue; } gridRowToSaveModel(child, panels, isSnapshot); @@ -151,7 +156,6 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) if (gridItem instanceof PanelRepeaterGridItem) { vizPanel = gridItem.state.source; - x = gridItem.state.x ?? 0; y = gridItem.state.y ?? 0; w = gridItem.state.width ?? 0; @@ -171,6 +175,7 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] }, transformations: [], transparent: vizPanel.state.displayMode === 'transparent', + ...vizPanelDataToPanel(vizPanel, isSnapshot), }; const panelTime = vizPanel.state.$timeRange; @@ -181,8 +186,22 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) panel.hideTimeOverride = panelTime.state.hideTimeOverride; } + if (gridItem instanceof PanelRepeaterGridItem) { + panel.repeat = gridItem.state.variableName; + panel.maxPerRow = gridItem.state.maxPerRow; + panel.repeatDirection = gridItem.getRepeatDirection(); + } + + return panel; +} + +function vizPanelDataToPanel( + vizPanel: VizPanel, + isSnapshot = false +): Pick { const dataProvider = vizPanel.state.$data; + const panel: Pick = {}; // Dashboard datasource handling if (dataProvider instanceof ShareQueryDataProvider) { panel.datasource = { @@ -252,16 +271,64 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) ]; } - if (gridItem instanceof PanelRepeaterGridItem) { - panel.repeat = gridItem.state.variableName; - panel.maxPerRow = gridItem.state.maxPerRow; - panel.repeatDirection = gridItem.getRepeatDirection(); - } - return panel; } +export function panelRepeaterToPanels(repeater: PanelRepeaterGridItem, isSnapshot = false): Panel[] { + if (!isSnapshot) { + return [gridItemToPanel(repeater)]; + } else { + if (repeater.state.repeatedPanels) { + const itemHeight = repeater.state.itemHeight ?? 10; + const rowCount = Math.ceil(repeater.state.repeatedPanels!.length / repeater.getMaxPerRow()); + const columnCount = Math.ceil(repeater.state.repeatedPanels!.length / rowCount); + const w = 24 / columnCount; + const h = itemHeight; + 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 localVariable = panel.state.$variables!.getByName(repeater.state.variableName!) as LocalValueVariable; + + const result: Panel = { + id: getPanelIdForVizPanel(panel), + type: panel.state.pluginId, + title: panel.state.title, + gridPos, + options: panel.state.options, + fieldConfig: (panel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] }, + transformations: [], + transparent: panel.state.displayMode === 'transparent', + // @ts-expect-error scopedVars are runtime only properties, not part of the persisted Dashboardmodel + scopedVars: { + [repeater.state.variableName!]: { + text: localVariable?.state.text, + value: localVariable?.state.value, + }, + }, + ...vizPanelDataToPanel(panel, isSnapshot), + }; + return result; + }); + + return panels; + } + + return []; + } +} + export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array, isSnapshot = false) { + const collapsed = Boolean(gridRow.state.isCollapsed); const rowPanel: RowPanel = { type: 'row', id: getPanelIdForVizPanel(gridRow), @@ -272,21 +339,52 @@ export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array { + return { + ...acc, + [variable.state.name]: { + text: variable.state.text, + value: variable.state.value, + }, + }; + }, {}); + // @ts-expect-error + rowPanel.scopedVars = scopedVars; + } + } + panelsArray.push(rowPanel); - const panelsInsideRow = gridRow.state.children.map((c) => gridItemToPanel(c, isSnapshot)); + let panelsInsideRow: Panel[] = []; + + if (isSnapshot) { + gridRow.state.children.forEach((c) => { + if (c instanceof PanelRepeaterGridItem) { + // Perform snapshot only for uncollapsed rows + panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, !collapsed)); + } else { + // Perform snapshot only for uncollapsed panels + panelsInsideRow.push(gridItemToPanel(c, !collapsed)); + } + }); + } else { + panelsInsideRow = gridRow.state.children.map((c) => gridItemToPanel(c)); + } if (gridRow.state.isCollapsed) { rowPanel.panels = panelsInsideRow; diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index 269e2423082..f0fae6b310b 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -1,7 +1,23 @@ -import { DeepPartial, SceneDeactivationHandler, SceneObject } from '@grafana/scenes'; +import { + DeepPartial, + EmbeddedScene, + SceneDeactivationHandler, + SceneGridItem, + SceneGridLayout, + SceneGridRow, + SceneObject, + SceneTimeRange, + SceneVariableSet, + TestVariable, + VizPanel, +} from '@grafana/scenes'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { DashboardDTO } from 'app/types'; +import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; + export function setupLoadDashboardMock(rsp: DeepPartial) { const loadDashboardMock = jest.fn().mockResolvedValue(rsp); setDashboardLoaderSrv({ @@ -69,3 +85,101 @@ export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHand } }; } + +interface SceneOptions { + variableQueryTime: number; + maxPerRow?: number; + itemHeight?: number; + repeatDirection?: RepeatDirection; + x?: number; + y?: number; + numberOfOptions?: number; + usePanelRepeater?: boolean; + useRowRepeater?: boolean; +} + +export function buildPanelRepeaterScene(options: SceneOptions) { + const defaults = { usePanelRepeater: true, ...options }; + + const repeater = new PanelRepeaterGridItem({ + variableName: 'server', + repeatedPanels: [], + repeatDirection: options.repeatDirection, + maxPerRow: options.maxPerRow, + itemHeight: options.itemHeight, + source: new VizPanel({ + title: 'Panel $server', + pluginId: 'timeseries', + }), + x: options.x || 0, + y: options.y || 0, + }); + + const gridItem = new SceneGridItem({ + x: 0, + y: 0, + width: 10, + height: 10, + body: new VizPanel({ title: 'Panel $server', pluginId: 'timeseries' }), + }); + + const rowChildren = defaults.usePanelRepeater ? repeater : gridItem; + + const row = new SceneGridRow({ + $behaviors: defaults.useRowRepeater + ? [ + new RowRepeaterBehavior({ + variableName: 'handler', + sources: [rowChildren], + }), + ] + : [], + children: defaults.useRowRepeater ? [] : [rowChildren], + }); + + const panelRepeatVariable = new TestVariable({ + name: 'server', + query: 'A.*', + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + isMulti: true, + includeAll: true, + delayMs: options.variableQueryTime, + optionsToReturn: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + { label: 'C', value: '3' }, + { label: 'D', value: '4' }, + { label: 'E', value: '5' }, + ].slice(0, options.numberOfOptions), + }); + + const rowRepeatVariable = new TestVariable({ + name: 'handler', + query: 'A.*', + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + isMulti: true, + includeAll: true, + delayMs: options.variableQueryTime, + optionsToReturn: [ + { label: 'AA', value: '11' }, + { label: 'BB', value: '22' }, + { label: 'CC', value: '33' }, + { label: 'DD', value: '44' }, + { label: 'EE', value: '55' }, + ].slice(0, options.numberOfOptions), + }); + + const scene = new EmbeddedScene({ + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + $variables: new SceneVariableSet({ + variables: [panelRepeatVariable, rowRepeatVariable], + }), + body: new SceneGridLayout({ + children: [row], + }), + }); + + return { scene, repeater, row, variable: panelRepeatVariable }; +}