From c63a52958df95d7fcda7c80c409b6a8c5c4c9946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 25 Jun 2025 10:02:42 +0200 Subject: [PATCH] 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> --- .../edit-pane/DashboardEditPane.tsx | 3 + .../edit-pane/DashboardEditableElement.tsx | 7 +- .../edit-pane/DashboardOutline.tsx | 84 ++---- .../dashboard-scene/edit-pane/shared.ts | 7 + .../AutoGridLayoutManager.tsx | 7 +- .../DefaultGridLayoutManager.tsx | 18 ++ .../SceneGridRowEditableElement.tsx | 5 +- .../scene/layout-rows/RowItem.tsx | 29 +- .../scene/layout-rows/RowItemEditor.tsx | 2 +- .../layout-rows/RowItemRepeater.test.tsx | 165 +++++++++++ .../scene/layout-rows/RowItemRepeater.tsx | 160 +++++++++++ .../RowItemRepeaterBehavior.test.tsx | 272 ------------------ .../layout-rows/RowItemRepeaterBehavior.ts | 161 ----------- .../scene/layout-rows/RowsLayoutManager.tsx | 47 +-- .../layout-rows/RowsLayoutManagerRenderer.tsx | 20 +- .../scene/layout-tabs/TabItem.tsx | 5 +- .../scene/layout-tabs/TabsLayoutManager.tsx | 12 +- .../scene/types/DashboardLayoutManager.ts | 5 + .../scene/types/EditableDashboardElement.ts | 10 +- .../RowsLayoutSerializer.test.ts | 10 +- .../layoutSerializers/RowsLayoutSerializer.ts | 22 +- .../transformSaveModelToScene.test.ts | 6 +- .../transformSaveModelToScene.ts | 3 +- .../variables/VariableSetEditableElement.tsx | 5 +- .../features/dashboard-scene/utils/utils.ts | 3 + public/locales/en-US/grafana.json | 1 + public/test/test-utils.tsx | 9 +- 27 files changed, 485 insertions(+), 593 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx delete mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx delete mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 1da83352d74..bdcee40b3dd 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -14,6 +14,7 @@ import { ConditionalRenderingChangedEvent, DashboardEditActionEvent, DashboardEditActionEventPayload, + DashboardStateChangedEvent, NewObjectAddedToCanvasEvent, ObjectRemovedFromCanvasEvent, ObjectsReorderedOnCanvasEvent, @@ -114,6 +115,7 @@ export class DashboardEditPane extends SceneObjectBase { } action.undo(); + action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true); /** * Some edit actions also require clearing selection or selecting new objects @@ -138,6 +140,7 @@ export class DashboardEditPane extends SceneObjectBase { */ private performAction(action: DashboardEditActionEventPayload) { action.perform(); + action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true); if (action.addedObject) { this.newObjectAddedToCanvas(action.addedObject); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index d63bc7345db..c3e04ba136f 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -1,6 +1,7 @@ import { ReactNode, useMemo, useRef } from 'react'; import { Trans, t } from '@grafana/i18n'; +import { SceneObject } from '@grafana/scenes'; import { Button, Input, TextArea } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; 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'), icon: 'apps', 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[] { const dashboard = this.dashboard; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index fe7bb82014e..bda1336db94 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -1,15 +1,12 @@ import { css, cx } from '@emotion/css'; -import { sortBy } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; 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 { getDashboardSceneFor } from '../utils/utils'; @@ -47,11 +44,11 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline const noTitleText = t('dashboard.outline.tree-item.no-title', ''); - const children = sortBy(collectEditableElementChildren(sceneObject, [], 0), 'depth'); + const children = editableElement.getOutlineChildren?.() ?? []; const elementInfo = editableElement.getEditableElementInfo(); const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName; - const elementCollapsed = editableElement.getCollapsedState?.(); const outlineRename = useOutlineRename(editableElement); + const isContainer = editableElement.getOutlineChildren ? true : false; const onNodeClicked = (e: React.MouseEvent) => { e.stopPropagation(); @@ -67,20 +64,8 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline const onToggleCollapse = (evt: React.MouseEvent) => { evt.stopPropagation(); 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 ( // todo: add proper keyboard navigation // eslint-disable-next-line jsx-a11y/click-events-have-key-events @@ -93,7 +78,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline >
- {elementInfo.isContainer && ( + {isContainer && (
- {elementInfo.isContainer && !isCollapsed && ( + {isContainer && !isCollapsed && (
    {children.length > 0 ? ( children.map((child) => ( - + )) ) : ( @@ -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; -} diff --git a/public/app/features/dashboard-scene/edit-pane/shared.ts b/public/app/features/dashboard-scene/edit-pane/shared.ts index 1d47a9694dc..dc5b9553142 100644 --- a/public/app/features/dashboard-scene/edit-pane/shared.ts +++ b/public/app/features/dashboard-scene/edit-pane/shared.ts @@ -93,6 +93,13 @@ export class DashboardEditActionEvent extends BusEventWithPayload { + static type = 'dashboard-state-changed'; +} + export interface AddElementActionHelperProps { addedObject: SceneObject; source: SceneObject; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index d960c4bba76..12cd129773c 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -1,5 +1,5 @@ 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 { GRID_CELL_VMARGIN } from 'app/core/constants'; 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) { const panelId = dashboardSceneGraph.getNextPanelId(this); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 0b244a93e8c..c4e1c481b0e 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -15,6 +15,7 @@ import { SceneGridItemLike, useSceneObjectState, SceneGridLayoutDragStartEvent, + SceneObject, } from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; 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 { return this.clone({ grid: this.state.grid.clone({ diff --git a/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx index 26918b8fb03..17be830a0bd 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx @@ -28,10 +28,13 @@ export class SceneGridRowEditableElement implements EditableDashboardElement, Bu typeName: t('dashboard.edit-pane.elements.row', 'Row'), instanceName: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'), icon: 'list-ul', - isContainer: true, }; } + public getOutlineChildren() { + return this._row.state.children; + } + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { const row = this._row; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index b23338f27df..c1b81b2138e 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -32,7 +32,6 @@ import { LayoutParent } from '../types/LayoutParent'; import { useEditOptions } from './RowItemEditor'; import { RowItemRenderer } from './RowItemRenderer'; -import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; import { RowItems } from './RowItems'; import { RowsLayoutManager } from './RowsLayoutManager'; @@ -44,6 +43,8 @@ export interface RowItemState extends SceneObjectState { fillScreen?: boolean; isDropTarget?: boolean; conditionalRendering?: ConditionalRendering; + repeatByVariable?: string; + repeatedRows?: RowItem[]; } export class RowItem @@ -86,10 +87,13 @@ export class RowItem typeName: t('dashboard.edit-pane.elements.row', 'Row'), instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'), icon: 'list-ul', - isContainer: true, }; } + public getOutlineChildren(): SceneObject[] { + return this.state.layout.getOutlineChildren(); + } + public getLayout(): DashboardLayoutManager { return this.state.layout; } @@ -176,10 +180,6 @@ export class RowItem this.setIsDropTarget(false); } - public getRepeatVariable(): string | undefined { - return this._getRepeatBehavior()?.state.variableName; - } - public onChangeTitle(title: string) { this.setState({ title }); } @@ -197,19 +197,10 @@ export class RowItem } public onChangeRepeat(repeat: string | undefined) { - let repeatBehavior = this._getRepeatBehavior(); - if (repeat) { - // Remove repeat behavior if it exists to trigger repeat when adding new one - if (repeatBehavior) { - repeatBehavior.removeBehavior(); - } - - repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat }); - this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] }); - repeatBehavior.activate(); + this.setState({ repeatByVariable: repeat }); } else { - repeatBehavior?.removeBehavior(); + this.setState({ repeatedRows: undefined, $variables: undefined, repeatByVariable: undefined }); } } @@ -221,10 +212,6 @@ export class RowItem return sceneGraph.getAncestor(this, RowsLayoutManager); } - private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined { - return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior); - } - public scrollIntoView() { scrollCanvasElementIntoView(this, this.containerRef); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index aa5e0c21df1..396e4075587 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -132,7 +132,7 @@ function RowRepeatSelect({ row }: { row: RowItem }) { <> row.onChangeRepeat(repeat)} /> {isAnyPanelUsingDashboardDS ? ( diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx new file mode 100644 index 00000000000..c6ac4f4066b --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx @@ -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(); + + return { scene, layout, rows, rowToRepeat, repeatByVariable }; +} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx new file mode 100644 index 00000000000..a8e473e7a6b --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -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 ; + } + + return ( + <> + + {repeatedRows?.map((rowClone) => )} + + ); +} + +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; +} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx deleted file mode 100644 index ca967ce3d06..00000000000 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx +++ /dev/null @@ -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; -} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts deleted file mode 100644 index 49b4e9c0a25..00000000000 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts +++ /dev/null @@ -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 { - 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)); -} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 474109e1a5e..f72262af37b 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -3,6 +3,7 @@ import { sceneGraph, SceneGridItemLike, SceneGridRow, + SceneObject, SceneObjectBase, SceneObjectState, VizPanel, @@ -24,7 +25,6 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { RowItem } from './RowItem'; -import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer'; interface RowsLayoutManagerState extends SceneObjectState { @@ -33,7 +33,6 @@ interface RowsLayoutManagerState extends SceneObjectState { export class RowsLayoutManager extends SceneObjectBase implements DashboardLayoutManager { public static Component = RowLayoutManagerRenderer; - public readonly isDashboardLayoutManager = true; public static readonly descriptor: LayoutRegistryItem = { @@ -120,26 +119,26 @@ export class RowsLayoutManager extends SceneObjectBase i this.addNewRow(row); } - public activateRepeaters() { - this.state.rows.forEach((row) => { - if (!row.isActive) { - row.activate(); - } - - const behavior = (row.state.$behaviors ?? []).find((b) => b instanceof RowItemRepeaterBehavior); - - if (!behavior?.isActive) { - behavior?.activate(); - } - - row.getLayout().activateRepeaters?.(); - }); - } - public shouldUngroup(): boolean { return this.state.rows.length === 1; } + public getOutlineChildren() { + const outlineChildren: SceneObject[] = []; + + for (const row of this.state.rows) { + outlineChildren.push(row); + + if (row.state.repeatedRows) { + for (const clone of row.state.repeatedRows!) { + outlineChildren.push(clone); + } + } + } + + return outlineChildren; + } + public removeRow(row: RowItem) { // When removing last row replace ourselves with the inner row layout if (this.shouldUngroup()) { @@ -198,12 +197,14 @@ export class RowsLayoutManager extends SceneObjectBase i conditionalRendering?.clearParent(); const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior); - const $behaviors = !behavior - ? undefined - : [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })]; 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) { @@ -253,12 +254,12 @@ export class RowsLayoutManager extends SceneObjectBase i new RowItem({ title: rowConfig.title, collapse: !!rowConfig.isCollapsed, + repeatByVariable: rowConfig.repeat, layout: DefaultGridLayoutManager.fromGridItems( rowConfig.children, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, rowConfig.isResizable ?? layout.state.grid.state.isResizable ), - $behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [], }) ); } else { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index ea2c0ac27f1..17c19241f83 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -4,12 +4,14 @@ import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; 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 { useDashboardState } from '../../utils/utils'; import { useClipboardState } from '../layouts-shared/useClipboardState'; +import { RowItem } from './RowItem'; +import { RowItemRepeater } from './RowItemRepeater'; import { RowsLayoutManager } from './RowsLayoutManager'; export function RowLayoutManagerRenderer({ model }: SceneComponentProps) { @@ -37,7 +39,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps (
    {rows.map((row) => ( - + ))} {dropProvided.placeholder} {isEditing && ( @@ -71,6 +73,20 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps; + } + } + + return ; +} + function getStyles(theme: GrafanaTheme2) { return { wrapper: css({ diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 7974c700b09..7335bb7563e 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -84,10 +84,13 @@ export class TabItem typeName: t('dashboard.edit-pane.elements.tab', 'Tab'), instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'), icon: 'layers', - isContainer: true, }; } + public getOutlineChildren(): SceneObject[] { + return this.state.layout.getOutlineChildren(); + } + public getLayout(): DashboardLayoutManager { return this.state.layout; } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 40ae7ac0602..3abfb5e937d 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -14,7 +14,6 @@ import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsL import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; import { RowItem } from '../layout-rows/RowItem'; -import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { getTabFromClipboard } from '../layouts-shared/paste'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; @@ -134,6 +133,10 @@ export class TabsLayoutManager extends SceneObjectBase i }); } + public getOutlineChildren() { + return this.state.tabs; + } + public addNewTab(tab?: TabItem) { const newTab = tab ?? new TabItem({}); 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 i const conditionalRendering = row.state.conditionalRendering; conditionalRendering?.clearParent(); - const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior); - const $behaviors = !behavior - ? undefined - : [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })]; + const $behaviors = row.state.repeatByVariable + ? [new TabItemRepeaterBehavior({ variableName: row.state.repeatByVariable })] + : undefined; tabs.push( new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors }) diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts index 680b8530601..679404b6cec 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -81,6 +81,11 @@ export interface DashboardLayoutManager extends SceneObject { * Paste a panel from the clipboard */ pastePanel?(): void; + + /** + * Get children for outline + */ + getOutlineChildren(): SceneObject[]; } export interface LayoutManagerSerializer { diff --git a/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts index b58d3f2ae80..11e512a5cf7 100644 --- a/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts +++ b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import { IconName } from '@grafana/data'; +import { SceneObject } from '@grafana/scenes'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; /** @@ -69,16 +70,17 @@ export interface EditableDashboardElement { * Used to change name from outline */ onChangeName?(name: string): { errorMessage?: string } | void; + + /** + * Container objects can have children + */ + getOutlineChildren?(): SceneObject[]; } export interface EditableDashboardElementInfo { instanceName: string; typeName: string; icon: IconName; - /** - * Mark it as a container of other editable elements - */ - isContainer?: boolean; isHidden?: boolean; } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts index 4c46fb706bb..9a41a35bb2b 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts @@ -5,7 +5,6 @@ import { AutoGridLayout } from '../../scene/layout-auto-grid/AutoGridLayout'; import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; import { RowItem } from '../../scene/layout-rows/RowItem'; -import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; import { deserializeRowsLayout, serializeRowsLayout } from './RowsLayoutSerializer'; @@ -169,12 +168,7 @@ describe('deserialization', () => { expect(deserialized.state.rows).toHaveLength(1); const row = deserialized.state.rows[0]; - expect(row.state.$behaviors).toBeDefined(); - 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'); + expect(row.state.repeatByVariable).toBe('foo'); }); }); @@ -270,7 +264,7 @@ describe('serialization', () => { isResizable: true, }), }), - $behaviors: [new RowItemRepeaterBehavior({ variableName: 'foo' })], + repeatByVariable: 'foo', }), ], }); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index 49a8cf94870..0b039b09a63 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -4,7 +4,6 @@ import { } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen'; import { RowItem } from '../../scene/layout-rows/RowItem'; -import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; import { isClonedKey } from '../../utils/clone'; @@ -30,6 +29,12 @@ export function serializeRow(row: RowItem): RowsLayoutRowKind { layout: layout, fillScreen: row.state.fillScreen, 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; } - 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; } @@ -72,16 +67,13 @@ export function deserializeRow( panelIdGenerator?: () => number ): RowItem { const layout = row.spec.layout; - const $behaviors = !row.spec.repeat - ? undefined - : [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })]; return new RowItem({ title: row.spec.title, collapse: row.spec.collapse, hideHeader: row.spec.hideHeader, fillScreen: row.spec.fillScreen, - $behaviors, + repeatByVariable: row.spec.repeat?.value, layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), conditionalRendering: getConditionalRendering(row), }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 84900146d50..cc1e2ee2ec1 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -33,7 +33,6 @@ import { PanelTimeRange } from '../scene/PanelTimeRange'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; -import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; import { NEW_LINK } from '../settings/links/utils'; import { getQueryRunnerFor } from '../utils/utils'; @@ -845,10 +844,7 @@ describe('transformSaveModelToScene', () => { const row2 = layout.state.rows[1]; - expect(row2.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior); - - const repeatBehavior = row2.state.$behaviors?.[0] as RowItemRepeaterBehavior; - expect(repeatBehavior.state.variableName).toBe('server'); + expect(row2.state.repeatByVariable).toBe('server'); const lastRow = layout.state.rows[layout.state.rows.length - 1]; expect(lastRow.state.title).toBe('Row at the bottom - not repeated - saved collapsed '); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 85fda654f43..2c51bee4427 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -45,7 +45,6 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { RowActions } from '../scene/layout-default/row-actions/RowActions'; import { RowItem } from '../scene/layout-rows/RowItem'; -import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; 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), }), }), - $behaviors: row.repeat ? [new RowItemRepeaterBehavior({ variableName: row.repeat })] : undefined, + repeatByVariable: row.repeat, }); return rowItem; } diff --git a/public/app/features/dashboard-scene/settings/variables/VariableSetEditableElement.tsx b/public/app/features/dashboard-scene/settings/variables/VariableSetEditableElement.tsx index 06f9b0727af..859e250c643 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableSetEditableElement.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableSetEditableElement.tsx @@ -28,10 +28,13 @@ export class VariableSetEditableElement implements EditableDashboardElement { typeName: t('dashboard.edit-pane.elements.variable-set', 'Variables'), icon: 'x', instanceName: t('dashboard.edit-pane.elements.variable-set', 'Variables'), - isContainer: true, }; } + public getOutlineChildren() { + return this.set.state.variables; + } + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { const set = this.set; diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index d8cfdab6992..84880a788b7 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -13,6 +13,7 @@ import { VizPanel, VizPanelMenu, } from '@grafana/scenes'; +import { createLogger } from '@grafana/ui'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour'; @@ -468,3 +469,5 @@ export function useInterpolatedTitle" diff --git a/public/test/test-utils.tsx b/public/test/test-utils.tsx index 3ec03531c99..5ed6982bc91 100644 --- a/public/test/test-utils.tsx +++ b/public/test/test-utils.tsx @@ -10,7 +10,12 @@ import { Router } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat'; 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 { ModalsContextProvider } from 'app/core/context/ModalsContextProvider'; import { configureStore } from 'app/store/configureStore'; @@ -106,6 +111,8 @@ const customRender = ( const store = renderOptions.preloadedState ? configureStore(renderOptions?.preloadedState) : undefined; const AllTheProviders = renderOptions.wrapper || getWrapper({ store, renderWithRouter, ...renderOptions }); + setChromeHeaderHeightHook(() => 40); + return { ...render(ui, { wrapper: AllTheProviders, ...renderOptions }), /** Instance of `userEvent.setup()` ready for use to interact with rendered component */