RowsLayout: Rethinking row repeats (#104312)

* Something is working

* Update

* Update

* working

* clear repeated rows

* Update

* Update

* Outline via function

* Update

* Update

* Update

* Progress

* Update

* Udpate sum

* Update

* Update public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Update

* more tests

* Update

* Update

* Removed old behavior

* Update

* Update

* Update

* fix outline for default grid

* Update

---------

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
pull/107375/head
Torkel Ödegaard 4 weeks ago committed by GitHub
parent 249be02010
commit c63a52958d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
  2. 7
      public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx
  3. 84
      public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx
  4. 7
      public/app/features/dashboard-scene/edit-pane/shared.ts
  5. 7
      public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx
  6. 18
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  7. 5
      public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx
  8. 29
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  9. 2
      public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx
  10. 165
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx
  11. 160
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx
  12. 272
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
  13. 161
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts
  14. 43
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
  15. 20
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx
  16. 5
      public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
  17. 12
      public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
  18. 5
      public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts
  19. 10
      public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts
  20. 10
      public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts
  21. 22
      public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
  22. 6
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
  23. 3
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  24. 5
      public/app/features/dashboard-scene/settings/variables/VariableSetEditableElement.tsx
  25. 3
      public/app/features/dashboard-scene/utils/utils.ts
  26. 1
      public/locales/en-US/grafana.json
  27. 9
      public/test/test-utils.tsx

@ -14,6 +14,7 @@ import {
ConditionalRenderingChangedEvent, ConditionalRenderingChangedEvent,
DashboardEditActionEvent, DashboardEditActionEvent,
DashboardEditActionEventPayload, DashboardEditActionEventPayload,
DashboardStateChangedEvent,
NewObjectAddedToCanvasEvent, NewObjectAddedToCanvasEvent,
ObjectRemovedFromCanvasEvent, ObjectRemovedFromCanvasEvent,
ObjectsReorderedOnCanvasEvent, ObjectsReorderedOnCanvasEvent,
@ -114,6 +115,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
} }
action.undo(); action.undo();
action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true);
/** /**
* Some edit actions also require clearing selection or selecting new objects * Some edit actions also require clearing selection or selecting new objects
@ -138,6 +140,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
*/ */
private performAction(action: DashboardEditActionEventPayload) { private performAction(action: DashboardEditActionEventPayload) {
action.perform(); action.perform();
action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true);
if (action.addedObject) { if (action.addedObject) {
this.newObjectAddedToCanvas(action.addedObject); this.newObjectAddedToCanvas(action.addedObject);

@ -1,6 +1,7 @@
import { ReactNode, useMemo, useRef } from 'react'; import { ReactNode, useMemo, useRef } from 'react';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Button, Input, TextArea } from '@grafana/ui'; import { Button, Input, TextArea } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -22,10 +23,14 @@ export class DashboardEditableElement implements EditableDashboardElement {
typeName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'), typeName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'),
icon: 'apps', icon: 'apps',
instanceName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'), instanceName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'),
isContainer: true,
}; };
} }
public getOutlineChildren(): SceneObject[] {
const { $variables, body } = this.dashboard.state;
return [$variables!, ...body.getOutlineChildren()];
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const dashboard = this.dashboard; const dashboard = this.dashboard;

@ -1,15 +1,12 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { sortBy } from 'lodash'; import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes'; import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Text, useElementSelection, useStyles2 } from '@grafana/ui'; import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { isInCloneChain } from '../utils/clone'; import { isInCloneChain } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils'; import { getDashboardSceneFor } from '../utils/utils';
@ -47,11 +44,11 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>'); const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const children = sortBy(collectEditableElementChildren(sceneObject, [], 0), 'depth'); const children = editableElement.getOutlineChildren?.() ?? [];
const elementInfo = editableElement.getEditableElementInfo(); const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName; const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const elementCollapsed = editableElement.getCollapsedState?.();
const outlineRename = useOutlineRename(editableElement); const outlineRename = useOutlineRename(editableElement);
const isContainer = editableElement.getOutlineChildren ? true : false;
const onNodeClicked = (e: React.MouseEvent) => { const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -67,20 +64,8 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
const onToggleCollapse = (evt: React.MouseEvent) => { const onToggleCollapse = (evt: React.MouseEvent) => {
evt.stopPropagation(); evt.stopPropagation();
setIsCollapsed(!isCollapsed); setIsCollapsed(!isCollapsed);
// Sync expanded state with canvas element
if (editableElement.getCollapsedState) {
editableElement.setCollapsedState?.(!isCollapsed);
}
}; };
// Sync canvas element expanded state with outline element
useEffect(() => {
if (elementCollapsed === !isCollapsed) {
setIsCollapsed(elementCollapsed);
}
}, [isCollapsed, elementCollapsed]);
return ( return (
// todo: add proper keyboard navigation // todo: add proper keyboard navigation
// eslint-disable-next-line jsx-a11y/click-events-have-key-events // eslint-disable-next-line jsx-a11y/click-events-have-key-events
@ -93,7 +78,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
> >
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}> <div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
<div className={styles.indentation}></div> <div className={styles.indentation}></div>
{elementInfo.isContainer && ( {isContainer && (
<button <button
className={styles.angleButton} className={styles.angleButton}
onClick={onToggleCollapse} onClick={onToggleCollapse}
@ -120,24 +105,25 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
/> />
) : ( ) : (
<> <>
<span>{instanceName}</span> <Stack direction="row" gap={0.5} alignItems="center" grow={1}>
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />} <span>{instanceName}</span>
{elementInfo.isContainer && isCollapsed && <span>({children.length})</span>} {elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
</Stack>
{isCloned && (
<span>
<Trans i18nKey="dashboard.outline.repeated-item">Repeat</Trans>
</span>
)}
</> </>
)} )}
</button> </button>
</div> </div>
{elementInfo.isContainer && !isCollapsed && ( {isContainer && !isCollapsed && (
<ul className={styles.nodeChildren} role="group"> <ul className={styles.nodeChildren} role="group">
{children.length > 0 ? ( {children.length > 0 ? (
children.map((child) => ( children.map((child) => (
<DashboardOutlineNode <DashboardOutlineNode key={child.state.key} sceneObject={child} editPane={editPane} depth={depth + 1} />
key={child.sceneObject.state.key}
sceneObject={child.sceneObject}
editPane={editPane}
depth={depth + 1}
/>
)) ))
) : ( ) : (
<Text color="secondary" element="li"> <Text color="secondary" element="li">
@ -249,41 +235,3 @@ function getStyles(theme: GrafanaTheme2) {
}), }),
}; };
} }
interface EditableElementConfig {
sceneObject: SceneObject;
editableElement: EditableDashboardElement;
depth: number;
}
function collectEditableElementChildren(
sceneObject: SceneObject,
children: EditableElementConfig[],
depth: number
): EditableElementConfig[] {
sceneObject.forEachChild((child) => {
const editableElement = getEditableElementFor(child);
if (editableElement) {
children.push({ sceneObject: child, editableElement, depth });
return;
}
if (child instanceof DashboardGridItem) {
// DashboardGridItem is a special case as it can contain repeated panels
// In this case, we want to show the repeated panels as separate items, otherwise show the body panel
if (child.state.repeatedPanels?.length) {
for (const repeatedPanel of child.state.repeatedPanels) {
const editableElement = getEditableElementFor(repeatedPanel)!;
children.push({ sceneObject: repeatedPanel, editableElement, depth });
}
return;
}
}
collectEditableElementChildren(child, children, depth + 1);
});
return children;
}

@ -93,6 +93,13 @@ export class DashboardEditActionEvent extends BusEventWithPayload<DashboardEditA
static type = 'dashboard-edit-action'; static type = 'dashboard-edit-action';
} }
/**
* Emitted after DashboardEditActionEvent has been processed (or undone)
*/
export class DashboardStateChangedEvent extends BusEventWithPayload<{ source: SceneObject }> {
static type = 'dashboard-state-changed';
}
export interface AddElementActionHelperProps { export interface AddElementActionHelperProps {
addedObject: SceneObject; addedObject: SceneObject;
source: SceneObject; source: SceneObject;

@ -1,5 +1,5 @@
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { GRID_CELL_VMARGIN } from 'app/core/constants'; import { GRID_CELL_VMARGIN } from 'app/core/constants';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -88,6 +88,11 @@ export class AutoGridLayoutManager
}); });
} }
public getOutlineChildren(): SceneObject[] {
const outlineChildren = this.state.layout.state.children.map((gridItem) => gridItem.state.body);
return outlineChildren;
}
public addPanel(vizPanel: VizPanel) { public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this); const panelId = dashboardSceneGraph.getNextPanelId(this);

@ -15,6 +15,7 @@ import {
SceneGridItemLike, SceneGridItemLike,
useSceneObjectState, useSceneObjectState,
SceneGridLayoutDragStartEvent, SceneGridLayoutDragStartEvent,
SceneObject,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
@ -399,6 +400,23 @@ export class DefaultGridLayoutManager
}); });
} }
public getOutlineChildren(): SceneObject[] {
const children: SceneObject[] = [];
for (const child of this.state.grid.state.children) {
// Flatten repeated grid items
if (child instanceof DashboardGridItem) {
if (child.state.repeatedPanels) {
children.push(...child.state.repeatedPanels);
} else {
children.push(child.state.body);
}
}
}
return children;
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({ return this.clone({
grid: this.state.grid.clone({ grid: this.state.grid.clone({

@ -28,10 +28,13 @@ export class SceneGridRowEditableElement implements EditableDashboardElement, Bu
typeName: t('dashboard.edit-pane.elements.row', 'Row'), typeName: t('dashboard.edit-pane.elements.row', 'Row'),
instanceName: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'), instanceName: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'),
icon: 'list-ul', icon: 'list-ul',
isContainer: true,
}; };
} }
public getOutlineChildren() {
return this._row.state.children;
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const row = this._row; const row = this._row;

@ -32,7 +32,6 @@ import { LayoutParent } from '../types/LayoutParent';
import { useEditOptions } from './RowItemEditor'; import { useEditOptions } from './RowItemEditor';
import { RowItemRenderer } from './RowItemRenderer'; import { RowItemRenderer } from './RowItemRenderer';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowItems } from './RowItems'; import { RowItems } from './RowItems';
import { RowsLayoutManager } from './RowsLayoutManager'; import { RowsLayoutManager } from './RowsLayoutManager';
@ -44,6 +43,8 @@ export interface RowItemState extends SceneObjectState {
fillScreen?: boolean; fillScreen?: boolean;
isDropTarget?: boolean; isDropTarget?: boolean;
conditionalRendering?: ConditionalRendering; conditionalRendering?: ConditionalRendering;
repeatByVariable?: string;
repeatedRows?: RowItem[];
} }
export class RowItem export class RowItem
@ -86,10 +87,13 @@ export class RowItem
typeName: t('dashboard.edit-pane.elements.row', 'Row'), typeName: t('dashboard.edit-pane.elements.row', 'Row'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'), instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'list-ul', icon: 'list-ul',
isContainer: true,
}; };
} }
public getOutlineChildren(): SceneObject[] {
return this.state.layout.getOutlineChildren();
}
public getLayout(): DashboardLayoutManager { public getLayout(): DashboardLayoutManager {
return this.state.layout; return this.state.layout;
} }
@ -176,10 +180,6 @@ export class RowItem
this.setIsDropTarget(false); this.setIsDropTarget(false);
} }
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}
public onChangeTitle(title: string) { public onChangeTitle(title: string) {
this.setState({ title }); this.setState({ title });
} }
@ -197,19 +197,10 @@ export class RowItem
} }
public onChangeRepeat(repeat: string | undefined) { public onChangeRepeat(repeat: string | undefined) {
let repeatBehavior = this._getRepeatBehavior();
if (repeat) { if (repeat) {
// Remove repeat behavior if it exists to trigger repeat when adding new one this.setState({ repeatByVariable: repeat });
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
repeatBehavior.activate();
} else { } else {
repeatBehavior?.removeBehavior(); this.setState({ repeatedRows: undefined, $variables: undefined, repeatByVariable: undefined });
} }
} }
@ -221,10 +212,6 @@ export class RowItem
return sceneGraph.getAncestor(this, RowsLayoutManager); return sceneGraph.getAncestor(this, RowsLayoutManager);
} }
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
}
public scrollIntoView() { public scrollIntoView() {
scrollCanvasElementIntoView(this, this.containerRef); scrollCanvasElementIntoView(this, this.containerRef);
} }

@ -132,7 +132,7 @@ function RowRepeatSelect({ row }: { row: RowItem }) {
<> <>
<RepeatRowSelect2 <RepeatRowSelect2
sceneContext={dashboard} sceneContext={dashboard}
repeat={row.getRepeatVariable()} repeat={row.state.repeatByVariable}
onChange={(repeat) => row.onChangeRepeat(repeat)} onChange={(repeat) => row.onChangeRepeat(repeat)}
/> />
{isAnyPanelUsingDashboardDS ? ( {isAnyPanelUsingDashboardDS ? (

@ -0,0 +1,165 @@
import { act, screen, waitFor } from '@testing-library/react';
import { render } from 'test/test-utils';
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, SceneVariableSet, TestVariable, VariableValueOption, PanelBuilders } from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
import { DashboardScene } from '../DashboardScene';
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { RowItem } from './RowItem';
import { RowsLayoutManager } from './RowsLayoutManager';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
setPluginImportUtils({
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: () => undefined,
});
describe('RowItemRepeater', () => {
describe('Given scene with variable with 3 values', () => {
it('Should repeat row', async () => {
const { rowToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Row A')).toBeInTheDocument();
expect(screen.queryByText('Row B')).toBeInTheDocument();
expect(screen.queryByText('Row C')).toBeInTheDocument();
});
expect(rowToRepeat.state.key).toBe('row-1-clone-0');
expect(rowToRepeat.state.repeatedRows!.length).toBe(2);
expect(rowToRepeat.state.repeatedRows![0].state.key).toBe('row-1-clone-1');
});
it('Should update repeats when variable value changes', async () => {
const { repeatByVariable, rowToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Row C')).toBeInTheDocument();
});
act(() => {
repeatByVariable.changeValueTo(['C', 'D']);
});
await waitFor(() => {
expect(screen.queryByText('Row A')).not.toBeInTheDocument();
expect(screen.queryByText('Row D')).toBeInTheDocument();
});
expect(rowToRepeat.state.repeatedRows!.length).toBe(1);
});
it('Should skip update repeats when variable values the same', async () => {
const { repeatByVariable, rowToRepeat } = renderScene({ variableQueryTime: 0 });
let stateUpdates = 0;
rowToRepeat.subscribeToState((s) => stateUpdates++);
await waitFor(() => {
expect(screen.queryByText('Row C')).toBeInTheDocument();
});
act(() => {
repeatByVariable.changeValueTo(['A1', 'B1', 'C1']);
});
expect(stateUpdates).toBe(1);
});
it('Should handle removing repeats', async () => {
const { rowToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Row C')).toBeInTheDocument();
});
act(() => {
rowToRepeat.onChangeRepeat(undefined);
});
expect(screen.queryByText('Row C')).not.toBeInTheDocument();
expect(rowToRepeat.state.$variables).toBe(undefined);
expect(rowToRepeat.state.repeatedRows).toBe(undefined);
expect(rowToRepeat.state.repeatByVariable).toBe(undefined);
});
});
});
interface SceneOptions {
variableQueryTime: number;
variableRefresh?: VariableRefresh;
}
function buildTextPanel(key: string, content: string) {
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
panel.setState({ key });
return panel;
}
function renderScene(
options: SceneOptions,
variableOptions?: VariableValueOption[],
variableStateOverrides?: { isMulti: boolean }
) {
const rows = [
new RowItem({
key: 'row-1',
title: 'Row $server',
repeatByVariable: 'server',
layout: new AutoGridLayoutManager({
layout: new AutoGridLayout({
children: [
new AutoGridItem({
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
}),
],
}),
}),
}),
];
const layout = new RowsLayoutManager({ rows });
const repeatByVariable = new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
refresh: options.variableRefresh,
optionsToReturn: variableOptions ?? [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
],
...variableStateOverrides,
});
const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [repeatByVariable],
}),
body: layout,
});
const rowToRepeat = rows[0];
render(<scene.Component model={scene} />);
return { scene, layout, rows, rowToRepeat, repeatByVariable };
}

@ -0,0 +1,160 @@
import { isEqual } from 'lodash';
import { useEffect } from 'react';
import {
MultiValueVariable,
SceneVariableSet,
LocalValueVariable,
sceneGraph,
VariableValueSingle,
} from '@grafana/scenes';
import { Spinner } from '@grafana/ui';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey } from '../../utils/clone';
import { dashboardLog, getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { RowItem } from './RowItem';
import { RowsLayoutManager } from './RowsLayoutManager';
export interface Props {
row: RowItem;
manager: RowsLayoutManager;
variable: MultiValueVariable;
}
export function RowItemRepeater({
row,
variable,
}: {
row: RowItem;
manager: RowsLayoutManager;
variable: MultiValueVariable;
}) {
const { repeatedRows } = row.useState();
// Subscribe to variable state changes and perform repeats when the variable changes
useEffect(() => {
performRowRepeats(variable, row, false);
const variableChangeSub = variable.subscribeToState((state) => performRowRepeats(variable, row, false));
const editEventSub = row.subscribeToEvent(DashboardStateChangedEvent, (e) =>
performRowRepeats(variable, row, true)
);
return () => {
editEventSub.unsubscribe();
variableChangeSub.unsubscribe();
};
}, [variable, row]);
if (
repeatedRows === undefined ||
sceneGraph.hasVariableDependencyInLoadingState(variable) ||
variable.state.loading
) {
dashboardLog.logger('RowItemRepeater', false, 'Variable is loading, showing spinner');
return <Spinner />;
}
return (
<>
<row.Component model={row} key={row.state.key!} />
{repeatedRows?.map((rowClone) => <rowClone.Component model={rowClone} key={rowClone.state.key!} />)}
</>
);
}
export function performRowRepeats(variable: MultiValueVariable, row: RowItem, contentChanged: boolean) {
if (sceneGraph.hasVariableDependencyInLoadingState(variable)) {
dashboardLog.logger('RowItemRepeater', false, 'Skipped dependency in loading state');
return;
}
if (variable.state.loading) {
dashboardLog.logger('RowItemRepeater', false, 'Skipped, variable is loading');
return;
}
const { values, texts } = getMultiVariableValues(variable);
const prevValues = getPrevRepeatValues(row, variable.state.name);
if (!contentChanged && isEqual(prevValues, values)) {
dashboardLog.logger('RowItemRepeater', false, 'Skipped, values the same');
return;
}
if (contentChanged) {
dashboardLog.logger('RowItemRepeater', false, 'Performing repeats, contentChanged');
} else {
dashboardLog.logger('RowItemRepeater', false, 'Performing repeats, variable values changed', values);
}
const variableValues = values.length ? values : [''];
const variableTexts = texts.length ? texts : variable.hasAllValue() ? ['All'] : ['None'];
const clonedRows: RowItem[] = [];
// Loop through variable values and create repeats
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
const isSourceRow = rowIndex === 0;
const rowCloneKey = getCloneKey(row.state.key!, rowIndex);
const rowClone = isSourceRow
? row
: row.clone({ repeatByVariable: undefined, repeatedRows: undefined, layout: undefined });
const layout = isSourceRow ? row.getLayout() : row.getLayout().cloneLayout(rowCloneKey, false);
rowClone.setState({
key: rowCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: variable.state.name,
value: variableValues[rowIndex],
text: String(variableTexts[rowIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
layout,
});
if (!isSourceRow) {
clonedRows.push(rowClone);
}
}
row.setState({ repeatedRows: clonedRows });
row.publishEvent(new DashboardRepeatsProcessedEvent({ source: row }), true);
}
/**
* Get previous variable values given the current repeated state
*/
function getPrevRepeatValues(mainRow: RowItem, varName: string): VariableValueSingle[] {
const values: VariableValueSingle[] = [];
if (!mainRow.state.repeatedRows) {
return [];
}
function collectVariableValue(row: RowItem) {
const variable = sceneGraph.lookupVariable(varName, row);
if (variable) {
const value = variable.getValue();
if (value != null && !Array.isArray(value)) {
values.push(value);
}
}
}
collectVariableValue(mainRow);
for (const row of mainRow.state.repeatedRows) {
collectVariableValue(row);
}
return values;
}

@ -1,272 +0,0 @@
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import {
SceneGridRow,
SceneTimeRange,
SceneVariableSet,
TestVariable,
VariableValueOption,
PanelBuilders,
} from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowsLayoutManager } from './RowsLayoutManager';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
setPluginImportUtils({
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: () => undefined,
});
describe('RowItemRepeaterBehavior', () => {
describe('Given scene with variable with 5 values', () => {
let scene: DashboardScene, layout: RowsLayoutManager, repeatBehavior: RowItemRepeaterBehavior;
let layoutStateUpdates: unknown[];
beforeEach(async () => {
({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
layoutStateUpdates = [];
layout.subscribeToState((state) => layoutStateUpdates.push(state));
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
});
it('Should repeat row', () => {
// Verify that first row still has repeat behavior
const row1 = layout.state.rows[0];
expect(row1.state.key).toBe(getCloneKey('row-1', 0));
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
const row1Children = getRowChildren(row1);
expect(row1Children[0].state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-0'));
expect(row1Children[0].state.body?.state.key).toBe(joinCloneKeys(row1Children[0].state.key!, 'panel-0'));
const row2 = layout.state.rows[1];
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
expect(row2.state.$behaviors).toEqual([]);
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
const row2Children = getRowChildren(row2);
expect(row2Children[0].state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-0'));
expect(row2Children[0].state.body?.state.key).toBe(joinCloneKeys(row2Children[0].state.key!, 'panel-0'));
});
it('Repeated rows should be read only', () => {
const row1 = layout.state.rows[0];
expect(isInCloneChain(row1.state.key!)).toBe(false);
const row2 = layout.state.rows[1];
expect(isInCloneChain(row2.state.key!)).toBe(true);
});
it('Should push row at the bottom down', () => {
// Should push row at the bottom down
const rowAtTheBottom = layout.state.rows[5];
expect(rowAtTheBottom.state.title).toBe('Row at the bottom');
});
it('Should handle second repeat cycle and update remove old repeats', async () => {
// trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated rows (and the panel above + the row at the bottom)
expect(layout.state.rows.length).toBe(3);
});
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(layoutStateUpdates.length).toBe(1);
});
});
describe('Given scene with variable with 15 values', () => {
let scene: DashboardScene, layout: RowsLayoutManager;
let layoutStateUpdates: unknown[];
beforeEach(async () => {
({ scene, layout } = buildScene({ variableQueryTime: 0 }, [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
{ label: 'D', value: 'D1' },
{ label: 'E', value: 'E1' },
{ label: 'F', value: 'F1' },
{ label: 'G', value: 'G1' },
{ label: 'H', value: 'H1' },
{ label: 'I', value: 'I1' },
{ label: 'J', value: 'J1' },
{ label: 'K', value: 'K1' },
{ label: 'L', value: 'L1' },
{ label: 'M', value: 'M1' },
{ label: 'N', value: 'N1' },
{ label: 'O', value: 'O1' },
]));
layoutStateUpdates = [];
layout.subscribeToState((state) => layoutStateUpdates.push(state));
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
});
it('Should handle second repeat cycle and update remove old repeats', async () => {
// should have 15 repeated rows (and the panel above)
expect(layout.state.rows.length).toBe(16);
// trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated rows (and the panel above)
expect(layout.state.rows.length).toBe(3);
});
});
describe('Given a scene with empty variable', () => {
it('Should preserve repeat row', async () => {
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
// Should have 2 rows, one without repeat and one with the dummy row
expect(layout.state.rows.length).toBe(2);
expect(layout.state.rows[0].state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
});
});
});
interface SceneOptions {
variableQueryTime: number;
variableRefresh?: VariableRefresh;
}
function buildTextPanel(key: string, content: string) {
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
panel.setState({ key });
return panel;
}
function buildScene(
options: SceneOptions,
variableOptions?: VariableValueOption[],
variableStateOverrides?: { isMulti: boolean }
) {
const repeatBehavior = new RowItemRepeaterBehavior({ variableName: 'server' });
const rows = [
new RowItem({
key: 'row-1',
$behaviors: [repeatBehavior],
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'grid-item-1',
x: 0,
y: 11,
width: 24,
height: 5,
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
}),
]),
}),
new RowItem({
key: 'row-2',
title: 'Row at the bottom',
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'grid-item-2',
x: 0,
y: 17,
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
}),
new DashboardGridItem({
key: 'grid-item-3',
x: 0,
y: 25,
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),
}),
]),
}),
];
const layout = new RowsLayoutManager({ rows });
const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
refresh: options.variableRefresh,
optionsToReturn: variableOptions ?? [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
{ label: 'D', value: 'D1' },
{ label: 'E', value: 'E1' },
],
...variableStateOverrides,
}),
],
}),
body: layout,
});
const rowToRepeat = repeatBehavior.parent as SceneGridRow;
return { scene, layout, rows, repeatBehavior, rowToRepeat };
}
function getRowLayout(row: RowItem): DefaultGridLayoutManager {
const layout = row.getLayout();
if (!(layout instanceof DefaultGridLayoutManager)) {
throw new Error('Invalid layout');
}
return layout;
}
function getRowChildren(row: RowItem): DashboardGridItem[] {
const layout = getRowLayout(row);
const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
if (filteredChildren.length !== layout.state.grid.state.children.length) {
throw new Error('Invalid layout');
}
return filteredChildren;
}

@ -1,161 +0,0 @@
import { isEqual } from 'lodash';
import {
LocalValueVariable,
MultiValueVariable,
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneVariableSet,
VariableDependencyConfig,
VariableValueSingle,
} from '@grafana/scenes';
import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { RowItem } from './RowItem';
import { RowsLayoutManager } from './RowsLayoutManager';
interface RowItemRepeaterBehaviorState extends SceneObjectState {
variableName: string;
}
export class RowItemRepeaterBehavior extends SceneObjectBase<RowItemRepeaterBehaviorState> {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName],
onVariableUpdateCompleted: () => this.performRepeat(),
});
private _prevRepeatValues?: VariableValueSingle[];
private _clonedRows?: RowItem[];
public constructor(state: RowItemRepeaterBehaviorState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this.performRepeat();
}
private _getRow(): RowItem {
if (!(this.parent instanceof RowItem)) {
throw new Error('RepeatedRowItemBehavior: Parent is not a RowItem');
}
return this.parent;
}
private _getLayout(): RowsLayoutManager {
const layout = this._getRow().parent;
if (!(layout instanceof RowsLayoutManager)) {
throw new Error('RepeatedRowItemBehavior: Layout is not a RowsLayoutManager');
}
return layout;
}
public performRepeat(force = false) {
if (this._variableDependency.hasDependencyInLoadingState()) {
return;
}
const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!);
if (!variable) {
console.error('RepeatedRowItemBehavior: Variable not found');
return;
}
if (!(variable instanceof MultiValueVariable)) {
console.error('RepeatedRowItemBehavior: Variable is not a MultiValueVariable');
return;
}
const rowToRepeat = this._getRow();
const layout = this._getLayout();
const { values, texts } = getMultiVariableValues(variable);
// Do nothing if values are the same
if (isEqual(this._prevRepeatValues, values) && !force) {
return;
}
this._prevRepeatValues = values;
this._clonedRows = [];
const rowContent = rowToRepeat.getLayout();
// when variable has no options (due to error or similar) it will not render any panels at all
// adding a placeholder in this case so that there is at least empty panel that can display error
const emptyVariablePlaceholderOption = {
values: [''],
texts: variable.hasAllValue() ? ['All'] : ['None'],
};
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
// Loop through variable values and create repeats
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
const isSourceRow = rowIndex === 0;
const rowClone = isSourceRow ? rowToRepeat : rowToRepeat.clone({ $behaviors: [] });
const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex);
rowClone.setState({
key: rowCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: this.state.variableName,
value: variableValues[rowIndex],
text: String(variableTexts[rowIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
layout: rowContent.cloneLayout?.(rowCloneKey, isSourceRow),
});
this._clonedRows.push(rowClone);
}
updateLayout(layout, this._clonedRows, rowToRepeat.state.key!);
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public removeBehavior() {
const row = this._getRow();
const layout = this._getLayout();
const rows = getRowsFilterOutRepeatClones(layout, row.state.key!);
layout.setState({ rows });
// Remove behavior and the scoped local variable
row.setState({ $behaviors: row.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
}
}
function updateLayout(layout: RowsLayoutManager, rows: RowItem[], rowKey: string) {
const allRows = getRowsFilterOutRepeatClones(layout, rowKey);
const index = allRows.findIndex((row) => row.state.key!.includes(rowKey));
if (index === -1) {
throw new Error('RowItemRepeaterBehavior: Row not found in layout');
}
layout.setState({ rows: [...allRows.slice(0, index), ...rows, ...allRows.slice(index + 1)] });
}
function getRowsFilterOutRepeatClones(layout: RowsLayoutManager, rowKey: string) {
return layout.state.rows.filter((rows) => !isClonedKeyOf(rows.state.key!, rowKey));
}

@ -3,6 +3,7 @@ import {
sceneGraph, sceneGraph,
SceneGridItemLike, SceneGridItemLike,
SceneGridRow, SceneGridRow,
SceneObject,
SceneObjectBase, SceneObjectBase,
SceneObjectState, SceneObjectState,
VizPanel, VizPanel,
@ -24,7 +25,6 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { RowItem } from './RowItem'; import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer'; import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
interface RowsLayoutManagerState extends SceneObjectState { interface RowsLayoutManagerState extends SceneObjectState {
@ -33,7 +33,6 @@ interface RowsLayoutManagerState extends SceneObjectState {
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager { export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
public static Component = RowLayoutManagerRenderer; public static Component = RowLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true; public readonly isDashboardLayoutManager = true;
public static readonly descriptor: LayoutRegistryItem = { public static readonly descriptor: LayoutRegistryItem = {
@ -120,24 +119,24 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
this.addNewRow(row); this.addNewRow(row);
} }
public activateRepeaters() { public shouldUngroup(): boolean {
this.state.rows.forEach((row) => { return this.state.rows.length === 1;
if (!row.isActive) { }
row.activate();
}
const behavior = (row.state.$behaviors ?? []).find((b) => b instanceof RowItemRepeaterBehavior); public getOutlineChildren() {
const outlineChildren: SceneObject[] = [];
if (!behavior?.isActive) { for (const row of this.state.rows) {
behavior?.activate(); outlineChildren.push(row);
}
row.getLayout().activateRepeaters?.(); if (row.state.repeatedRows) {
}); for (const clone of row.state.repeatedRows!) {
} outlineChildren.push(clone);
}
}
}
public shouldUngroup(): boolean { return outlineChildren;
return this.state.rows.length === 1;
} }
public removeRow(row: RowItem) { public removeRow(row: RowItem) {
@ -198,12 +197,14 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
conditionalRendering?.clearParent(); conditionalRendering?.clearParent();
const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior); const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
const $behaviors = !behavior
? undefined
: [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
rows.push( rows.push(
new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, $behaviors }) new RowItem({
layout: tab.state.layout.clone(),
title: tab.state.title,
conditionalRendering,
repeatByVariable: behavior?.state.variableName,
})
); );
} }
} else if (layout instanceof DefaultGridLayoutManager) { } else if (layout instanceof DefaultGridLayoutManager) {
@ -253,12 +254,12 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
new RowItem({ new RowItem({
title: rowConfig.title, title: rowConfig.title,
collapse: !!rowConfig.isCollapsed, collapse: !!rowConfig.isCollapsed,
repeatByVariable: rowConfig.repeat,
layout: DefaultGridLayoutManager.fromGridItems( layout: DefaultGridLayoutManager.fromGridItems(
rowConfig.children, rowConfig.children,
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
rowConfig.isResizable ?? layout.state.grid.state.isResizable rowConfig.isResizable ?? layout.state.grid.state.isResizable
), ),
$behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [],
}) })
); );
} else { } else {

@ -4,12 +4,14 @@ import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n'; import { Trans } from '@grafana/i18n';
import { SceneComponentProps } from '@grafana/scenes'; import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes';
import { Button, useStyles2 } from '@grafana/ui'; import { Button, useStyles2 } from '@grafana/ui';
import { useDashboardState } from '../../utils/utils'; import { useDashboardState } from '../../utils/utils';
import { useClipboardState } from '../layouts-shared/useClipboardState'; import { useClipboardState } from '../layouts-shared/useClipboardState';
import { RowItem } from './RowItem';
import { RowItemRepeater } from './RowItemRepeater';
import { RowsLayoutManager } from './RowsLayoutManager'; import { RowsLayoutManager } from './RowsLayoutManager';
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) { export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
@ -37,7 +39,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
{(dropProvided) => ( {(dropProvided) => (
<div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}> <div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{rows.map((row) => ( {rows.map((row) => (
<row.Component model={row} key={row.state.key!} /> <RowWrapper row={row} manager={model} key={row.state.key!} />
))} ))}
{dropProvided.placeholder} {dropProvided.placeholder}
{isEditing && ( {isEditing && (
@ -71,6 +73,20 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
); );
} }
function RowWrapper({ row, manager }: { row: RowItem; manager: RowsLayoutManager }) {
const { repeatByVariable } = useSceneObjectState(row, { shouldActivateOrKeepAlive: true });
if (repeatByVariable) {
const variable = sceneGraph.lookupVariable(repeatByVariable, manager);
if (variable instanceof MultiValueVariable) {
return <RowItemRepeater row={row} key={row.state.key!} manager={manager} variable={variable} />;
}
}
return <row.Component model={row} key={row.state.key!} />;
}
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
return { return {
wrapper: css({ wrapper: css({

@ -84,10 +84,13 @@ export class TabItem
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'), typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'), instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'layers', icon: 'layers',
isContainer: true,
}; };
} }
public getOutlineChildren(): SceneObject[] {
return this.state.layout.getOutlineChildren();
}
public getLayout(): DashboardLayoutManager { public getLayout(): DashboardLayoutManager {
return this.state.layout; return this.state.layout;
} }

@ -14,7 +14,6 @@ import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsL
import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils'; import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from '../layout-rows/RowItem'; import { RowItem } from '../layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { getTabFromClipboard } from '../layouts-shared/paste'; import { getTabFromClipboard } from '../layouts-shared/paste';
import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
@ -134,6 +133,10 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}); });
} }
public getOutlineChildren() {
return this.state.tabs;
}
public addNewTab(tab?: TabItem) { public addNewTab(tab?: TabItem) {
const newTab = tab ?? new TabItem({}); const newTab = tab ?? new TabItem({});
const existingNames = new Set(this.state.tabs.map((tab) => tab.state.title).filter((title) => title !== undefined)); const existingNames = new Set(this.state.tabs.map((tab) => tab.state.title).filter((title) => title !== undefined));
@ -283,10 +286,9 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
const conditionalRendering = row.state.conditionalRendering; const conditionalRendering = row.state.conditionalRendering;
conditionalRendering?.clearParent(); conditionalRendering?.clearParent();
const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior); const $behaviors = row.state.repeatByVariable
const $behaviors = !behavior ? [new TabItemRepeaterBehavior({ variableName: row.state.repeatByVariable })]
? undefined : undefined;
: [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })];
tabs.push( tabs.push(
new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors }) new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors })

@ -81,6 +81,11 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
* Paste a panel from the clipboard * Paste a panel from the clipboard
*/ */
pastePanel?(): void; pastePanel?(): void;
/**
* Get children for outline
*/
getOutlineChildren(): SceneObject[];
} }
export interface LayoutManagerSerializer { export interface LayoutManagerSerializer {

@ -1,6 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { IconName } from '@grafana/data'; import { IconName } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
/** /**
@ -69,16 +70,17 @@ export interface EditableDashboardElement {
* Used to change name from outline * Used to change name from outline
*/ */
onChangeName?(name: string): { errorMessage?: string } | void; onChangeName?(name: string): { errorMessage?: string } | void;
/**
* Container objects can have children
*/
getOutlineChildren?(): SceneObject[];
} }
export interface EditableDashboardElementInfo { export interface EditableDashboardElementInfo {
instanceName: string; instanceName: string;
typeName: string; typeName: string;
icon: IconName; icon: IconName;
/**
* Mark it as a container of other editable elements
*/
isContainer?: boolean;
isHidden?: boolean; isHidden?: boolean;
} }

@ -5,7 +5,6 @@ import { AutoGridLayout } from '../../scene/layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager'; import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowItem } from '../../scene/layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
import { deserializeRowsLayout, serializeRowsLayout } from './RowsLayoutSerializer'; import { deserializeRowsLayout, serializeRowsLayout } from './RowsLayoutSerializer';
@ -169,12 +168,7 @@ describe('deserialization', () => {
expect(deserialized.state.rows).toHaveLength(1); expect(deserialized.state.rows).toHaveLength(1);
const row = deserialized.state.rows[0]; const row = deserialized.state.rows[0];
expect(row.state.$behaviors).toBeDefined(); expect(row.state.repeatByVariable).toBe('foo');
const behaviors = row.state.$behaviors ?? [];
expect(behaviors).toHaveLength(1);
const repeaterBehavior = behaviors[0] as RowItemRepeaterBehavior;
expect(repeaterBehavior).toBeInstanceOf(RowItemRepeaterBehavior);
expect(repeaterBehavior.state.variableName).toBe('foo');
}); });
}); });
@ -270,7 +264,7 @@ describe('serialization', () => {
isResizable: true, isResizable: true,
}), }),
}), }),
$behaviors: [new RowItemRepeaterBehavior({ variableName: 'foo' })], repeatByVariable: 'foo',
}), }),
], ],
}); });

@ -4,7 +4,6 @@ import {
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowItem } from '../../scene/layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
import { isClonedKey } from '../../utils/clone'; import { isClonedKey } from '../../utils/clone';
@ -30,6 +29,12 @@ export function serializeRow(row: RowItem): RowsLayoutRowKind {
layout: layout, layout: layout,
fillScreen: row.state.fillScreen, fillScreen: row.state.fillScreen,
hideHeader: row.state.hideHeader, hideHeader: row.state.hideHeader,
...(row.state.repeatByVariable && {
repeat: {
mode: 'variable',
value: row.state.repeatByVariable,
},
}),
}, },
}; };
@ -39,16 +44,6 @@ export function serializeRow(row: RowItem): RowsLayoutRowKind {
rowKind.spec.conditionalRendering = conditionalRenderingRootGroup; rowKind.spec.conditionalRendering = conditionalRenderingRootGroup;
} }
if (row.state.$behaviors) {
for (const behavior of row.state.$behaviors) {
if (behavior instanceof RowItemRepeaterBehavior) {
if (rowKind.spec.repeat) {
throw new Error('Multiple repeaters are not supported');
}
rowKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' };
}
}
}
return rowKind; return rowKind;
} }
@ -72,16 +67,13 @@ export function deserializeRow(
panelIdGenerator?: () => number panelIdGenerator?: () => number
): RowItem { ): RowItem {
const layout = row.spec.layout; const layout = row.spec.layout;
const $behaviors = !row.spec.repeat
? undefined
: [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })];
return new RowItem({ return new RowItem({
title: row.spec.title, title: row.spec.title,
collapse: row.spec.collapse, collapse: row.spec.collapse,
hideHeader: row.spec.hideHeader, hideHeader: row.spec.hideHeader,
fillScreen: row.spec.fillScreen, fillScreen: row.spec.fillScreen,
$behaviors, repeatByVariable: row.spec.repeat?.value,
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
conditionalRendering: getConditionalRendering(row), conditionalRendering: getConditionalRendering(row),
}); });

@ -33,7 +33,6 @@ import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { NEW_LINK } from '../settings/links/utils'; import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils'; import { getQueryRunnerFor } from '../utils/utils';
@ -845,10 +844,7 @@ describe('transformSaveModelToScene', () => {
const row2 = layout.state.rows[1]; const row2 = layout.state.rows[1];
expect(row2.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior); expect(row2.state.repeatByVariable).toBe('server');
const repeatBehavior = row2.state.$behaviors?.[0] as RowItemRepeaterBehavior;
expect(repeatBehavior.state.variableName).toBe('server');
const lastRow = layout.state.rows[layout.state.rows.length - 1]; const lastRow = layout.state.rows[layout.state.rows.length - 1];
expect(lastRow.state.title).toBe('Row at the bottom - not repeated - saved collapsed '); expect(lastRow.state.title).toBe('Row at the bottom - not repeated - saved collapsed ');

@ -45,7 +45,6 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions'; import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { RowItem } from '../scene/layout-rows/RowItem'; import { RowItem } from '../scene/layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
@ -239,7 +238,7 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[]
children: (row.panels?.map((p) => buildGridItemForPanel(p)) ?? []).concat(panels), children: (row.panels?.map((p) => buildGridItemForPanel(p)) ?? []).concat(panels),
}), }),
}), }),
$behaviors: row.repeat ? [new RowItemRepeaterBehavior({ variableName: row.repeat })] : undefined, repeatByVariable: row.repeat,
}); });
return rowItem; return rowItem;
} }

@ -28,10 +28,13 @@ export class VariableSetEditableElement implements EditableDashboardElement {
typeName: t('dashboard.edit-pane.elements.variable-set', 'Variables'), typeName: t('dashboard.edit-pane.elements.variable-set', 'Variables'),
icon: 'x', icon: 'x',
instanceName: t('dashboard.edit-pane.elements.variable-set', 'Variables'), instanceName: t('dashboard.edit-pane.elements.variable-set', 'Variables'),
isContainer: true,
}; };
} }
public getOutlineChildren() {
return this.set.state.variables;
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const set = this.set; const set = this.set;

@ -13,6 +13,7 @@ import {
VizPanel, VizPanel,
VizPanelMenu, VizPanelMenu,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { createLogger } from '@grafana/ui';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour'; import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
@ -468,3 +469,5 @@ export function useInterpolatedTitle<T extends SceneObjectState & { title?: stri
export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrchestrator | undefined { export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrchestrator | undefined {
return getDashboardSceneFor(scene).state.layoutOrchestrator; return getDashboardSceneFor(scene).state.layoutOrchestrator;
} }
export const dashboardLog = createLogger('Dashboard');

@ -4593,6 +4593,7 @@
"title-matched_other": "Matched {{count}}/{{totalCount}} options" "title-matched_other": "Matched {{count}}/{{totalCount}} options"
}, },
"outline": { "outline": {
"repeated-item": "Repeat",
"tree-item": { "tree-item": {
"empty": "(empty)", "empty": "(empty)",
"no-title": "<no title>" "no-title": "<no title>"

@ -10,7 +10,12 @@ import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat'; import { CompatRouter } from 'react-router-dom-v5-compat';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { HistoryWrapper, LocationServiceProvider, setLocationService } from '@grafana/runtime'; import {
HistoryWrapper,
LocationServiceProvider,
setChromeHeaderHeightHook,
setLocationService,
} from '@grafana/runtime';
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider'; import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
@ -106,6 +111,8 @@ const customRender = (
const store = renderOptions.preloadedState ? configureStore(renderOptions?.preloadedState) : undefined; const store = renderOptions.preloadedState ? configureStore(renderOptions?.preloadedState) : undefined;
const AllTheProviders = renderOptions.wrapper || getWrapper({ store, renderWithRouter, ...renderOptions }); const AllTheProviders = renderOptions.wrapper || getWrapper({ store, renderWithRouter, ...renderOptions });
setChromeHeaderHeightHook(() => 40);
return { return {
...render(ui, { wrapper: AllTheProviders, ...renderOptions }), ...render(ui, { wrapper: AllTheProviders, ...renderOptions }),
/** Instance of `userEvent.setup()` ready for use to interact with rendered component */ /** Instance of `userEvent.setup()` ready for use to interact with rendered component */

Loading…
Cancel
Save