DashboardScene: Fixing major row repeat issues (#87539)

* DashboardScene: Fixing major row repeat issues

* Fixing edit scope

* Use dashboard variableDependendency to notify row repeat behaviors

* update scenes lib

* Do not repeat if values are the same

* Update public/app/features/dashboard-scene/scene/DashboardScene.tsx

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* Updated scenes

* Update

* Update

* Do not render row actions for repeated rows

* Fixed e2e

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
pull/87789/head
Torkel Ödegaard 1 year ago committed by GitHub
parent c9c6445554
commit 9cd7c87b48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      e2e/various-suite/solo-route.spec.ts
  2. 2
      package.json
  3. 17
      public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx
  4. 28
      public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx
  5. 8
      public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx
  6. 14
      public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts
  7. 28
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  8. 85
      public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx
  9. 91
      public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts
  10. 79
      public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx
  11. 95
      public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap
  12. 8
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  13. 2
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
  14. 18
      public/app/features/dashboard-scene/utils/test-utils.ts
  15. 15
      public/app/features/dashboard-scene/utils/utils.ts
  16. 64
      yarn.lock

@ -35,7 +35,7 @@ describe('Solo Route', () => {
it('Can view solo in repeaterd row and panel in scenes', () => { it('Can view solo in repeaterd row and panel in scenes', () => {
// open Panel Tests - Graph NG // open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit( e2e.pages.SoloPanel.visit(
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-row-2-clone-2&__feature.dashboardSceneSolo=true' 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true'
); );
e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist');

@ -258,7 +258,7 @@
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*", "@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "^4.14.0", "@grafana/scenes": "^4.21.0",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",

@ -113,16 +113,17 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
return; return;
} }
if (gridItem instanceof DashboardGridItem) { if (!(gridItem instanceof DashboardGridItem)) {
this.handleRepeatOptionChanges(gridItem);
} else {
console.error('Unsupported scene object type'); console.error('Unsupported scene object type');
return;
} }
this.commitChangesToSource(gridItem);
} }
private handleRepeatOptionChanges(panelRepeater: DashboardGridItem) { private commitChangesToSource(gridItem: DashboardGridItem) {
let width = panelRepeater.state.width ?? 1; let width = gridItem.state.width ?? 1;
let height = panelRepeater.state.height; let height = gridItem.state.height;
const panelManager = this.state.vizManager; const panelManager = this.state.vizManager;
const horizontalToVertical = const horizontalToVertical =
@ -130,12 +131,12 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
const verticalToHorizontal = const verticalToHorizontal =
this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h'; this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h';
if (horizontalToVertical) { if (horizontalToVertical) {
width = Math.floor(width / (panelRepeater.state.maxPerRow ?? 1)); width = Math.floor(width / (gridItem.state.maxPerRow ?? 1));
} else if (verticalToHorizontal) { } else if (verticalToHorizontal) {
width = 24; width = 24;
} }
panelRepeater.setState({ gridItem.setState({
body: panelManager.state.panel.clone(), body: panelManager.state.panel.clone(),
repeatDirection: panelManager.state.repeatDirection, repeatDirection: panelManager.state.repeatDirection,
variableName: panelManager.state.repeat, variableName: panelManager.state.repeat,

@ -4,7 +4,14 @@ import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingSta
import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField'; import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField';
import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry'; import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import {
LocalValueVariable,
SceneGridRow,
SceneQueryRunner,
SceneVariableSet,
VizPanel,
sceneGraph,
} from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { InspectTab } from 'app/features/inspector/types'; import { InspectTab } from 'app/features/inspector/types';
@ -813,6 +820,25 @@ describe('VizPanelManager', () => {
expect(vizPanelManager.state.datasource).toEqual(ds1Mock); expect(vizPanelManager.state.datasource).toEqual(ds1Mock);
expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock);
}); });
describe('Given a panel inside repeated row', () => {
it('Should include row variable scope', () => {
const { panel } = setupTest('panel-9');
const row = panel.parent?.parent;
if (!(row instanceof SceneGridRow)) {
throw new Error('Did not find parent row');
}
row.setState({
$variables: new SceneVariableSet({ variables: [new LocalValueVariable({ name: 'hello', value: 'A' })] }),
});
const editor = buildPanelEditScene(panel);
const variable = sceneGraph.lookupVariable('hello', editor.state.vizManager);
expect(variable?.getValue()).toBe('A');
});
});
}); });
const setupTest = (panelId: string) => { const setupTest = (panelId: string) => {

@ -23,6 +23,7 @@ import {
SceneObjectState, SceneObjectState,
SceneObjectStateChangedEvent, SceneObjectStateChangedEvent,
SceneQueryRunner, SceneQueryRunner,
SceneVariables,
VizPanel, VizPanel,
sceneUtils, sceneUtils,
} from '@grafana/scenes'; } from '@grafana/scenes';
@ -91,7 +92,14 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state; const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state;
repeatOptions = { repeat, repeatDirection, maxPerRow }; repeatOptions = { repeat, repeatDirection, maxPerRow };
let variables: SceneVariables | undefined;
if (gridItem.parent?.state.$variables) {
variables = gridItem.parent.state.$variables.clone();
}
return new VizPanelManager({ return new VizPanelManager({
$variables: variables,
panel: sourcePanel.clone(), panel: sourcePanel.clone(),
sourcePanel: sourcePanel.getRef(), sourcePanel: sourcePanel.getRef(),
...repeatOptions, ...repeatOptions,

@ -591,6 +591,18 @@ export const panelWithQueriesAndMixedDatasource = {
type: 'timeseries', type: 'timeseries',
}; };
const row = {
id: 8,
type: 'row',
gridPos: { h: 1, w: 24, x: 0, y: 20 },
};
const rowChild = {
id: 9,
type: 'timeseries',
gridPos: { h: 2, w: 24, x: 0, y: 21 },
};
export const testDashboard = { export const testDashboard = {
annotations: { annotations: {
list: [ list: [
@ -622,6 +634,8 @@ export const testDashboard = {
panelWithNoDataSource, panelWithNoDataSource,
panelWithDataSourceNotFound, panelWithDataSourceNotFound,
panelWithQueriesAndMixedDatasource, panelWithQueriesAndMixedDatasource,
row,
rowChild,
], ],
refresh: '', refresh: '',
schemaVersion: 39, schemaVersion: 39,

@ -64,6 +64,7 @@ import { DashboardGridItem } from './DashboardGridItem';
import { DashboardSceneRenderer } from './DashboardSceneRenderer'; import { DashboardSceneRenderer } from './DashboardSceneRenderer';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryVizPanel } from './LibraryVizPanel'; import { LibraryVizPanel } from './LibraryVizPanel';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { ScopesScene } from './ScopesScene'; import { ScopesScene } from './ScopesScene';
import { ViewPanelScene } from './ViewPanelScene'; import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { setupKeyboardShortcuts } from './keyboardShortcuts';
@ -127,7 +128,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
/** /**
* Get notified when variables change * Get notified when variables change
*/ */
protected _variableDependency = new DashboardVariableDependency(); protected _variableDependency = new DashboardVariableDependency(this);
/** /**
* State before editing started * State before editing started
@ -847,6 +848,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike { export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
private _emptySet = new Set<string>(); private _emptySet = new Set<string>();
public constructor(private _dashboard: DashboardScene) {}
getNames(): Set<string> { getNames(): Set<string> {
return this._emptySet; return this._emptySet;
} }
@ -860,5 +863,28 @@ export class DashboardVariableDependency implements SceneVariableDependencyConfi
// Temp solution for some core panels (like dashlist) to know that variables have changed // Temp solution for some core panels (like dashlist) to know that variables have changed
appEvents.publish(new VariablesChanged({ refreshAll: true, panelIds: [] })); appEvents.publish(new VariablesChanged({ refreshAll: true, panelIds: [] }));
} }
/**
* Propagate variable changes to repeat row behavior as it does not get it when it's nested under local value
* The first repeated row has the row repeater behavior but it also has a local SceneVariableSet with a local variable value
*/
const layout = this._dashboard.state.body;
if (!(layout instanceof SceneGridLayout)) {
return;
}
for (const child of layout.state.children) {
if (!(child instanceof SceneGridRow) || !child.state.$behaviors) {
continue;
}
for (const behavior of child.state.$behaviors) {
if (behavior instanceof RowRepeaterBehavior) {
if (behavior.isWaitingForVariables || (behavior.state.variableName === variable.state.name && hasChanged)) {
behavior.performRepeat();
}
}
}
}
} }
} }

@ -1,5 +1,4 @@
import { import {
EmbeddedScene,
SceneCanvasText, SceneCanvasText,
SceneGridLayout, SceneGridLayout,
SceneGridRow, SceneGridRow,
@ -13,14 +12,21 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/co
import { activateFullSceneTree } from '../utils/test-utils'; import { activateFullSceneTree } from '../utils/test-utils';
import { RepeatDirection } from './DashboardGridItem'; import { RepeatDirection } from './DashboardGridItem';
import { DashboardScene } from './DashboardScene';
import { RowRepeaterBehavior } from './RowRepeaterBehavior'; import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { RowActions } from './row-actions/RowActions';
describe('RowRepeaterBehavior', () => { describe('RowRepeaterBehavior', () => {
describe('Given scene with variable with 5 values', () => { describe('Given scene with variable with 5 values', () => {
let scene: EmbeddedScene, grid: SceneGridLayout; let scene: DashboardScene, grid: SceneGridLayout, repeatBehavior: RowRepeaterBehavior;
let gridStateUpdates: unknown[];
beforeEach(async () => { beforeEach(async () => {
({ scene, grid } = buildScene({ variableQueryTime: 0 })); ({ scene, grid, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
gridStateUpdates = [];
grid.subscribeToState((state) => gridStateUpdates.push(state));
activateFullSceneTree(scene); activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1)); await new Promise((r) => setTimeout(r, 1));
}); });
@ -28,17 +34,20 @@ describe('RowRepeaterBehavior', () => {
it('Should repeat row', () => { it('Should repeat row', () => {
// Verify that panel above row remains // Verify that panel above row remains
expect(grid.state.children[0]).toBeInstanceOf(SceneGridItem); expect(grid.state.children[0]).toBeInstanceOf(SceneGridItem);
// Verify that first row still has repeat behavior // Verify that first row still has repeat behavior
const row1 = grid.state.children[1] as SceneGridRow; const row1 = grid.state.children[1] as SceneGridRow;
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior);
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('1'); expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
expect(row1.state.actions).toBeDefined();
const row2 = grid.state.children[2] as SceneGridRow; const row2 = grid.state.children[2] as SceneGridRow;
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B'); expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
expect(row2.state.actions).toBeUndefined();
// Should give repeated panels unique keys // Should give repeated panels unique keys
const gridItem = row2.state.children[0] as SceneGridItem; const gridItem = row2.state.children[0] as SceneGridItem;
expect(gridItem.state.body?.state.key).toBe('canvas-1-row-1'); expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1');
}); });
it('Should push row at the bottom down', () => { it('Should push row at the bottom down', () => {
@ -66,24 +75,34 @@ describe('RowRepeaterBehavior', () => {
it('Should handle second repeat cycle and update remove old repeats', async () => { it('Should handle second repeat cycle and update remove old repeats', async () => {
// trigger another repeat cycle by changing the variable // trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable; const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['2', '3']); variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1)); await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated rows (and the panel above + the row at the bottom) // should now only have 2 repeated rows (and the panel above + the row at the bottom)
expect(grid.state.children.length).toBe(4); expect(grid.state.children.length).toBe(4);
}); });
it('Should ignore repeat process if variable values are the same', async () => {
// trigger another repeat cycle by changing the variable
repeatBehavior.performRepeat();
await new Promise((r) => setTimeout(r, 1));
expect(gridStateUpdates.length).toBe(1);
});
}); });
describe('Given scene empty row', () => { describe('Given scene empty row', () => {
let scene: EmbeddedScene; let scene: DashboardScene;
let grid: SceneGridLayout; let grid: SceneGridLayout;
let repeatBehavior: RowRepeaterBehavior; let rowToRepeat: SceneGridRow;
beforeEach(async () => { beforeEach(async () => {
({ scene, grid, repeatBehavior } = buildScene({ variableQueryTime: 0 })); ({ scene, grid, rowToRepeat } = buildScene({ variableQueryTime: 0 }));
rowToRepeat.setState({ children: [] });
repeatBehavior.setState({ sources: [] });
activateFullSceneTree(scene); activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1)); await new Promise((r) => setTimeout(r, 1));
}); });
@ -108,21 +127,7 @@ interface SceneOptions {
} }
function buildScene(options: SceneOptions) { function buildScene(options: SceneOptions) {
const repeatBehavior = new RowRepeaterBehavior({ const repeatBehavior = new RowRepeaterBehavior({ variableName: 'server' });
variableName: 'server',
sources: [
new SceneGridItem({
x: 0,
y: 11,
width: 24,
height: 5,
body: new SceneCanvasText({
key: 'canvas-1',
text: 'Panel inside repeated row, server = $server',
}),
}),
],
});
const grid = new SceneGridLayout({ const grid = new SceneGridLayout({
children: [ children: [
@ -140,7 +145,20 @@ function buildScene(options: SceneOptions) {
y: 10, y: 10,
width: 24, width: 24,
height: 1, height: 1,
actions: new RowActions({}),
$behaviors: [repeatBehavior], $behaviors: [repeatBehavior],
children: [
new SceneGridItem({
x: 0,
y: 11,
width: 24,
height: 5,
body: new SceneCanvasText({
key: 'canvas-1',
text: 'Panel inside repeated row, server = $server',
}),
}),
],
}), }),
new SceneGridRow({ new SceneGridRow({
x: 0, x: 0,
@ -148,6 +166,7 @@ function buildScene(options: SceneOptions) {
width: 24, width: 24,
height: 5, height: 5,
title: 'Row at the bottom', title: 'Row at the bottom',
children: [ children: [
new SceneGridItem({ new SceneGridItem({
key: 'griditem-2', key: 'griditem-2',
@ -172,7 +191,7 @@ function buildScene(options: SceneOptions) {
], ],
}); });
const scene = new EmbeddedScene({ const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({ $variables: new SceneVariableSet({
variables: [ variables: [
@ -185,11 +204,11 @@ function buildScene(options: SceneOptions) {
includeAll: true, includeAll: true,
delayMs: options.variableQueryTime, delayMs: options.variableQueryTime,
optionsToReturn: [ optionsToReturn: [
{ label: 'A', value: '1' }, { label: 'A', value: 'A1' },
{ label: 'B', value: '2' }, { label: 'B', value: 'B1' },
{ label: 'C', value: '3' }, { label: 'C', value: 'C1' },
{ label: 'D', value: '4' }, { label: 'D', value: 'D1' },
{ label: 'E', value: '5' }, { label: 'E', value: 'E1' },
], ],
}), }),
], ],
@ -197,5 +216,7 @@ function buildScene(options: SceneOptions) {
body: grid, body: grid,
}); });
return { scene, grid, repeatBehavior }; const rowToRepeat = repeatBehavior.parent as SceneGridRow;
return { scene, grid, repeatBehavior, rowToRepeat };
} }

@ -1,3 +1,5 @@
import { isEqual } from 'lodash';
import { import {
LocalValueVariable, LocalValueVariable,
MultiValueVariable, MultiValueVariable,
@ -18,7 +20,6 @@ import { DashboardRepeatsProcessedEvent } from './types';
interface RowRepeaterBehaviorState extends SceneObjectState { interface RowRepeaterBehaviorState extends SceneObjectState {
variableName: string; variableName: string;
sources: SceneGridItemLike[];
} }
/** /**
@ -28,9 +29,12 @@ interface RowRepeaterBehaviorState extends SceneObjectState {
export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorState> { export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorState> {
protected _variableDependency = new VariableDependencyConfig(this, { protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName], variableNames: [this.state.variableName],
onVariableUpdateCompleted: this._onVariableUpdateCompleted.bind(this), onVariableUpdateCompleted: () => {},
}); });
public isWaitingForVariables = false;
private _prevRepeatValues?: VariableValueSingle[];
public constructor(state: RowRepeaterBehaviorState) { public constructor(state: RowRepeaterBehaviorState) {
super(state); super(state);
@ -38,15 +42,31 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
} }
private _activationHandler() { private _activationHandler() {
this._performRepeat(); this.performRepeat();
} }
private _onVariableUpdateCompleted(): void { private _getRow(): SceneGridRow {
this._performRepeat(); if (!(this.parent instanceof SceneGridRow)) {
throw new Error('RepeatedRowBehavior: Parent is not a SceneGridRow');
}
return this.parent;
} }
private _performRepeat() { private _getLayout(): SceneGridLayout {
if (this._variableDependency.hasDependencyInLoadingState()) { const layout = sceneGraph.getLayout(this);
if (!(layout instanceof SceneGridLayout)) {
throw new Error('RepeatedRowBehavior: Layout is not a SceneGridLayout');
}
return layout;
}
public performRepeat() {
this.isWaitingForVariables = this._variableDependency.hasDependencyInLoadingState();
if (this.isWaitingForVariables) {
return; return;
} }
@ -62,40 +82,39 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
return; return;
} }
if (!(this.parent instanceof SceneGridRow)) { const rowToRepeat = this._getRow();
console.error('RepeatedRowBehavior: Parent is not a SceneGridRow'); const layout = this._getLayout();
return; const { values, texts } = getMultiVariableValues(variable);
}
const layout = sceneGraph.getLayout(this);
if (!(layout instanceof SceneGridLayout)) { // Do nothing if values are the same
console.error('RepeatedRowBehavior: Layout is not a SceneGridLayout'); if (isEqual(this._prevRepeatValues, values)) {
return; return;
} }
const rowToRepeat = this.parent; this._prevRepeatValues = values;
const { values, texts } = getMultiVariableValues(variable);
const rows: SceneGridRow[] = []; const rows: SceneGridRow[] = [];
const rowContentHeight = getRowContentHeight(this.state.sources); const rowContent = rowToRepeat.state.children;
const rowContentHeight = getRowContentHeight(rowContent);
let maxYOfRows = 0; let maxYOfRows = 0;
// Loop through variable values and create repeates // Loop through variable values and create repeates
for (let index = 0; index < values.length; index++) { for (let index = 0; index < values.length; index++) {
const children: SceneGridItemLike[] = []; const children: SceneGridItemLike[] = [];
const localValue = values[index];
// Loop through panels inside row // Loop through panels inside row
for (const source of this.state.sources) { for (const source of rowContent) {
const sourceItemY = source.state.y ?? 0; const sourceItemY = source.state.y ?? 0;
const itemY = sourceItemY + (rowContentHeight + 1) * index; const itemY = sourceItemY + (rowContentHeight + 1) * index;
const itemKey = index > 0 ? `${source.state.key}-clone-${localValue}` : source.state.key;
const itemClone = source.clone({ const itemClone = source.clone({ key: itemKey, y: itemY });
key: `${source.state.key}-clone-${index}`,
y: itemY,
});
//Make sure all the child scene objects have unique keys //Make sure all the child scene objects have unique keys
ensureUniqueKeys(itemClone, index); if (index > 0) {
ensureUniqueKeys(itemClone, localValue);
}
children.push(itemClone); children.push(itemClone);
@ -104,7 +123,7 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
} }
} }
const rowClone = this.getRowClone(rowToRepeat, index, values[index], texts[index], rowContentHeight, children); const rowClone = this.getRowClone(rowToRepeat, index, localValue, texts[index], rowContentHeight, children);
rows.push(rowClone); rows.push(rowClone);
} }
@ -136,15 +155,27 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const sourceRowY = rowToRepeat.state.y ?? 0; const sourceRowY = rowToRepeat.state.y ?? 0;
return rowToRepeat.clone({ return rowToRepeat.clone({
key: `${rowToRepeat.state.key}-clone-${index}`, key: `${rowToRepeat.state.key}-clone-${value}`,
$variables: new SceneVariableSet({ $variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: this.state.variableName, value, text: String(text) })], variables: [new LocalValueVariable({ name: this.state.variableName, value, text: String(text) })],
}), }),
$behaviors: [], $behaviors: [],
children, children,
y: sourceRowY + rowContentHeight * index + index, y: sourceRowY + rowContentHeight * index + index,
actions: undefined,
}); });
} }
public removeBehavior() {
const row = this._getRow();
const layout = this._getLayout();
const children = getLayoutChildrenFilterOutRepeatClones(this._getLayout(), this._getRow());
layout.setState({ children: children });
// Remove behavior and the scoped local variable
row.setState({ $behaviors: row.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
}
} }
function getRowContentHeight(panels: SceneGridItemLike[]): number { function getRowContentHeight(panels: SceneGridItemLike[]): number {
@ -207,9 +238,9 @@ function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowToRe
}); });
} }
function ensureUniqueKeys(item: SceneGridItemLike, rowIndex: number) { function ensureUniqueKeys(item: SceneGridItemLike, localValue: VariableValueSingle) {
item.forEachChild((child) => { item.forEachChild((child) => {
child.setState({ key: `${child.state.key}-row-${rowIndex}` }); child.setState({ key: `${child.state.key}-clone-${localValue}` });
ensureUniqueKeys(child, rowIndex); ensureUniqueKeys(child, localValue);
}); });
} }

@ -2,14 +2,7 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { import { SceneComponentProps, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
SceneComponentProps,
SceneGridLayout,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
VizPanel,
} from '@grafana/scenes';
import { Icon, TextLink, useStyles2 } from '@grafana/ui'; import { Icon, TextLink, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
@ -25,31 +18,6 @@ import { RowOptionsButton } from './RowOptionsButton';
export interface RowActionsState extends SceneObjectState {} export interface RowActionsState extends SceneObjectState {}
export class RowActions extends SceneObjectBase<RowActionsState> { export class RowActions extends SceneObjectBase<RowActionsState> {
private updateLayout(rowClone: SceneGridRow): void {
const row = this.getParent();
const layout = this.getDashboard().state.body;
if (!(layout instanceof SceneGridLayout)) {
throw new Error('Layout is not a SceneGridLayout');
}
// remove the repeated rows
const children = layout.state.children.filter((child) => !child.state.key?.startsWith(`${row.state.key}-clone-`));
// get the index to replace later
const index = children.indexOf(row);
if (index === -1) {
throw new Error('Parent row not found in layout children');
}
// replace the row with the clone
layout.setState({
children: [...children.slice(0, index), rowClone, ...children.slice(index + 1)],
});
}
public getParent(): SceneGridRow { public getParent(): SceneGridRow {
if (!(this.parent instanceof SceneGridRow)) { if (!(this.parent instanceof SceneGridRow)) {
throw new Error('RowActions must have a SceneGridRow parent'); throw new Error('RowActions must have a SceneGridRow parent');
@ -64,39 +32,26 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
public onUpdate = (title: string, repeat?: string | null): void => { public onUpdate = (title: string, repeat?: string | null): void => {
const row = this.getParent(); const row = this.getParent();
let repeatBehavior: RowRepeaterBehavior | undefined;
// return early if there is no repeat if (row.state.$behaviors) {
if (!repeat) { for (let b of row.state.$behaviors) {
const clone = row.clone(); if (b instanceof RowRepeaterBehavior) {
repeatBehavior = b;
// remove the row repeater behaviour, leave the rest }
clone.setState({ }
title,
$behaviors: row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [],
});
this.updateLayout(clone);
return;
} }
const children = row.state.children.map((child) => child.clone()); if (repeat && !repeatBehavior) {
const repeatBehavior = new RowRepeaterBehavior({ variableName: repeat });
const newBehaviour = new RowRepeaterBehavior({ row.setState({ $behaviors: [...(row.state.$behaviors ?? []), repeatBehavior] });
variableName: repeat, } else if (repeatBehavior) {
sources: children, repeatBehavior.removeBehavior();
}); }
// get rest of behaviors except the old row repeater, if any, and push new one
const behaviors = row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [];
behaviors.push(newBehaviour);
row.setState({
title,
$behaviors: behaviors,
});
newBehaviour.activate(); if (title !== row.state.title) {
row.setState({ title });
}
}; };
public onDelete = () => { public onDelete = () => {

@ -141,6 +141,101 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"title": "Row for server $server", "title": "Row for server $server",
"type": "row", "type": "row",
}, },
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic",
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false,
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear",
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none",
},
"thresholdsStyle": {
"mode": "off",
},
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
},
{
"color": "red",
"value": 80,
},
],
},
},
"overrides": [],
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 4,
},
"id": 2,
"maxPerRow": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
},
"tooltip": {
"mode": "single",
"sort": "none",
},
},
"repeat": "pod",
"repeatDirection": "h",
"targets": [
{
"alias": "server = $server, pod id = $pod ",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1,
},
],
"title": "server = $server, pod = $pod",
"type": "timeseries",
},
{ {
"collapsed": true, "collapsed": true,
"gridPos": { "gridPos": {

@ -176,13 +176,7 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]):
if (row.repeat) { if (row.repeat) {
// For repeated rows the children are stored in the behavior // For repeated rows the children are stored in the behavior
children = []; behaviors = [new RowRepeaterBehavior({ variableName: row.repeat })];
behaviors = [
new RowRepeaterBehavior({
variableName: row.repeat,
sources: content,
}),
];
} }
return new SceneGridRow({ return new SceneGridRow({

@ -226,7 +226,7 @@ describe('transformSceneToSaveModel', () => {
const rowRepeater = rowWithRepeat.state.$behaviors![0] as RowRepeaterBehavior; const rowRepeater = rowWithRepeat.state.$behaviors![0] as RowRepeaterBehavior;
// trigger row repeater // trigger row repeater
rowRepeater.variableDependency?.variableUpdateCompleted(variable, true); rowRepeater.performRepeat();
// Make sure the repeated rows have been added to runtime scene model // Make sure the repeated rows have been added to runtime scene model
expect(grid.state.children.length).toBe(5); expect(grid.state.children.length).toBe(5);

@ -68,6 +68,11 @@ export function mockResizeObserver() {
export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler { export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler {
const deactivationHandlers: SceneDeactivationHandler[] = []; const deactivationHandlers: SceneDeactivationHandler[] = [];
// Important that variables are activated before other children
if (scene.state.$variables) {
deactivationHandlers.push(activateFullSceneTree(scene.state.$variables));
}
scene.forEachChild((child) => { scene.forEachChild((child) => {
// For query runners which by default use the container width for maxDataPoints calculation we are setting a width. // For query runners which by default use the container width for maxDataPoints calculation we are setting a width.
// In real life this is done by the React component when VizPanel is rendered. // In real life this is done by the React component when VizPanel is rendered.
@ -130,18 +135,9 @@ export function buildPanelRepeaterScene(options: SceneOptions, source?: VizPanel
}), }),
}); });
const rowChildren = defaults.usePanelRepeater ? withRepeat : withoutRepeat;
const row = new SceneGridRow({ const row = new SceneGridRow({
$behaviors: defaults.useRowRepeater $behaviors: defaults.useRowRepeater ? [new RowRepeaterBehavior({ variableName: 'handler' })] : [],
? [ children: [defaults.usePanelRepeater ? withRepeat : withoutRepeat],
new RowRepeaterBehavior({
variableName: 'handler',
sources: [rowChildren],
}),
]
: [],
children: defaults.useRowRepeater ? [] : [rowChildren],
}); });
const panelRepeatVariable = new TestVariable({ const panelRepeatVariable = new TestVariable({

@ -64,7 +64,20 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
return null; return null;
} }
const panel = sceneGraph.findObject(scene, (obj) => obj.state.key === key); const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
if (objKey === key) {
return true;
}
if (!(obj instanceof VizPanel)) {
return false;
}
return false;
});
if (panel) { if (panel) {
if (panel instanceof VizPanel) { if (panel instanceof VizPanel) {
return panel; return panel;

@ -3938,9 +3938,9 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@grafana/scenes@npm:^4.14.0": "@grafana/scenes@npm:^4.21.0":
version: 4.20.0 version: 4.21.0
resolution: "@grafana/scenes@npm:4.20.0" resolution: "@grafana/scenes@npm:4.21.0"
dependencies: dependencies:
"@grafana/e2e-selectors": "npm:^10.4.1" "@grafana/e2e-selectors": "npm:^10.4.1"
react-grid-layout: "npm:1.3.4" react-grid-layout: "npm:1.3.4"
@ -3954,7 +3954,7 @@ __metadata:
"@grafana/ui": ^10.4.1 "@grafana/ui": ^10.4.1
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
checksum: 10/6acd11a1dbb7f79d41704024649f12ebd1bbfb451e6edff3b5a7dba237d1c6cb7934b06f8bb3fde732b676ce621aee32a66ecddea155893c1eadc93f41ad9d2f checksum: 10/6ce273c3a2969fedb0dda89cbe97efe1b93b7b5e67844da75f2dd08356a11d51d5153f56e5057ca5e2b37bab728a5d23794001b03b1a591e647fd80c1a58d3d6
languageName: node languageName: node
linkType: hard linkType: hard
@ -4662,8 +4662,8 @@ __metadata:
linkType: hard linkType: hard
"@kusto/monaco-kusto@npm:^10.0.0": "@kusto/monaco-kusto@npm:^10.0.0":
version: 10.0.21 version: 10.0.20
resolution: "@kusto/monaco-kusto@npm:10.0.21" resolution: "@kusto/monaco-kusto@npm:10.0.20"
dependencies: dependencies:
"@kusto/language-service": "npm:0.0.278" "@kusto/language-service": "npm:0.0.278"
"@kusto/language-service-next": "npm:11.5.3" "@kusto/language-service-next": "npm:11.5.3"
@ -4674,7 +4674,7 @@ __metadata:
monaco-editor: ^0.46.0 monaco-editor: ^0.46.0
bin: bin:
copyMonacoFilesAMD: copyMonacoFilesAMD.js copyMonacoFilesAMD: copyMonacoFilesAMD.js
checksum: 10/81640b12337239a90fde4432cf32e59d9ab77c8fc7442b20f5d16872f2084a750cd5c7654e61147ad68d40f55935d30115eb8b4d42e3a79c672e7934ce8efade checksum: 10/050cd997e8c30328cd2ac181a601bd6cc174023bf482d04e0e2655eb935a53c6dca25731cc0ac6fd365d3e0c388a9335896b156c763e86f3ce74bb0c19758154
languageName: node languageName: node
linkType: hard linkType: hard
@ -15434,16 +15434,16 @@ __metadata:
linkType: hard linkType: hard
"dompurify@npm:^2.2.0": "dompurify@npm:^2.2.0":
version: 2.5.3 version: 2.5.2
resolution: "dompurify@npm:2.5.3" resolution: "dompurify@npm:2.5.2"
checksum: 10/e5b4325e0b643bfd08c1d8500769d970924a1943b87976fb30c4e55d08bd7c3e7a09c1e1d1cb7f33425f72c1d643448c09e81209ef89a3e3fd01c4d713c94bc5 checksum: 10/18b292c489c2056de4f1b4492985d01a7c09e88bf72a74291527ff2493c2132d8a6a542cf23a1263c0e0f97aeb43d0081091bdd20553a8ecc3858fcd2bb9a968
languageName: node languageName: node
linkType: hard linkType: hard
"dompurify@npm:^3.0.0": "dompurify@npm:^3.0.0":
version: 3.1.3 version: 3.1.2
resolution: "dompurify@npm:3.1.3" resolution: "dompurify@npm:3.1.2"
checksum: 10/bb1badf23e8b8c32e116339ae70842465f35706be0d3b2c38a392f3ee1f32e73dbabee6462e9e89406a527e837100b75002b86d8f386937663448cbdf714c466 checksum: 10/9d5f4464d6b52aa540ac362a8e4354adc7dc33ef05a2b0a109cfcc1ba63c011b5ff38bbbf4f6b5a893166d976cbbd665c7aa4f5571cd010c812dc63a1f701f8b
languageName: node languageName: node
linkType: hard linkType: hard
@ -15573,13 +15573,13 @@ __metadata:
linkType: hard linkType: hard
"ejs@npm:^3.1.7, ejs@npm:^3.1.8": "ejs@npm:^3.1.7, ejs@npm:^3.1.8":
version: 3.1.10 version: 3.1.9
resolution: "ejs@npm:3.1.10" resolution: "ejs@npm:3.1.9"
dependencies: dependencies:
jake: "npm:^10.8.5" jake: "npm:^10.8.5"
bin: bin:
ejs: bin/cli.js ejs: bin/cli.js
checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 checksum: 10/71f56d37540d2c2d71701f0116710c676f75314a3e997ef8b83515d5d4d2b111c5a72725377caeecb928671bacb84a0d38135f345904812e989847057d59f21a
languageName: node languageName: node
linkType: hard linkType: hard
@ -18277,7 +18277,7 @@ __metadata:
"@grafana/prometheus": "workspace:*" "@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*" "@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*" "@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:^4.14.0" "@grafana/scenes": "npm:^4.21.0"
"@grafana/schema": "workspace:*" "@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*" "@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^1.3.0-rc1" "@grafana/tsconfig": "npm:^1.3.0-rc1"
@ -19317,11 +19317,11 @@ __metadata:
linkType: hard linkType: hard
"i18next@npm:^23.0.0, i18next@npm:^23.5.1": "i18next@npm:^23.0.0, i18next@npm:^23.5.1":
version: 23.11.4 version: 23.11.3
resolution: "i18next@npm:23.11.4" resolution: "i18next@npm:23.11.3"
dependencies: dependencies:
"@babel/runtime": "npm:^7.23.2" "@babel/runtime": "npm:^7.23.2"
checksum: 10/399314e2b53658d436801ce78a3c226e7a9fdc51db5320104c9337750dd84fdb6556601ec98c5114d81110c7026f845efea2086b4a972e4ece70da741e44581d checksum: 10/9d562ade19d0beba16683ff94967a6dedc0a32ce335d203c5a160f075ac5a9a7a9adb164085a6b7b69328568bc932a65b92664834c2bf3e15d8f3bff90f15353
languageName: node languageName: node
linkType: hard linkType: hard
@ -21418,8 +21418,8 @@ __metadata:
linkType: hard linkType: hard
"knip@npm:^5.10.0": "knip@npm:^5.10.0":
version: 5.15.1 version: 5.10.0
resolution: "knip@npm:5.15.1" resolution: "knip@npm:5.10.0"
dependencies: dependencies:
"@ericcornelissen/bash-parser": "npm:0.5.2" "@ericcornelissen/bash-parser": "npm:0.5.2"
"@nodelib/fs.walk": "npm:2.0.0" "@nodelib/fs.walk": "npm:2.0.0"
@ -21445,7 +21445,7 @@ __metadata:
bin: bin:
knip: bin/knip.js knip: bin/knip.js
knip-bun: bin/knip-bun.js knip-bun: bin/knip-bun.js
checksum: 10/5f8286ec7e36f1cd359244ac4dce63bfbe4946f08150593e510e0ea4f4c30305a6b2167dc76ff116d603be63e5e8833521cf7990e73c6f07c1ddd8de83e664e1 checksum: 10/eafc84cae08ddb249623c13814ebd1932abb1fb2e3d9c8061454cf2cb672ac478f6e1d63c4c80cb8af8d7ef05f0d03b40504894e4ed0d560c883c584d98d96ca
languageName: node languageName: node
linkType: hard linkType: hard
@ -22681,7 +22681,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"minipass@npm:^4.2.4": "minipass@npm:^4.0.0, minipass@npm:^4.2.4":
version: 4.2.8 version: 4.2.8
resolution: "minipass@npm:4.2.8" resolution: "minipass@npm:4.2.8"
checksum: 10/e148eb6dcb85c980234cad889139ef8ddf9d5bdac534f4f0268446c8792dd4c74f4502479be48de3c1cce2f6450f6da4d0d4a86405a8a12be04c1c36b339569a checksum: 10/e148eb6dcb85c980234cad889139ef8ddf9d5bdac534f4f0268446c8792dd4c74f4502479be48de3c1cce2f6450f6da4d0d4a86405a8a12be04c1c36b339569a
@ -29564,7 +29564,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tar@npm:6.2.1, tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.13, tar@npm:^6.1.2": "tar@npm:6.2.1":
version: 6.2.1 version: 6.2.1
resolution: "tar@npm:6.2.1" resolution: "tar@npm:6.2.1"
dependencies: dependencies:
@ -29578,6 +29578,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.13, tar@npm:^6.1.2":
version: 6.1.13
resolution: "tar@npm:6.1.13"
dependencies:
chownr: "npm:^2.0.0"
fs-minipass: "npm:^2.0.0"
minipass: "npm:^4.0.0"
minizlib: "npm:^2.1.1"
mkdirp: "npm:^1.0.3"
yallist: "npm:^4.0.0"
checksum: 10/add2c3c6d0d71192186ec118d265b92d94be5cd57a0b8fdf0d29ee46dc846574925a5fc57170eefffd78201eda4c45d7604070b5a4b0648e4d6e1d65918b5a82
languageName: node
linkType: hard
"teex@npm:^1.0.1": "teex@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "teex@npm:1.0.1" resolution: "teex@npm:1.0.1"

Loading…
Cancel
Save