From 61f5f215ee420b3fc8af3e52897189ee4aac8634 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Mon, 3 Feb 2025 11:46:47 +0200 Subject: [PATCH] Dashboards: Implement rows repeat in rows layout (#99300) --- .betterer.results | 18 -- e2e/old-arch/various-suite/solo-route.spec.ts | 12 +- e2e/various-suite/solo-route.spec.ts | 12 +- .../edit-pane/DashboardEditPane.tsx | 11 + .../scene/DashboardScene.test.tsx | 7 +- .../dashboard-scene/scene/DashboardScene.tsx | 21 +- .../scene/DashboardSceneUrlSync.test.ts | 8 +- .../scene/DashboardSceneUrlSync.ts | 9 +- .../scene/PanelMenuBehavior.tsx | 11 +- .../layout-default/DashboardGridItem.test.tsx | 6 +- .../layout-default/DashboardGridItem.tsx | 3 +- .../DefaultGridLayoutManager.test.tsx | 3 + .../DefaultGridLayoutManager.tsx | 109 ++++++++- .../RowRepeaterBehavior.test.tsx | 32 ++- .../RowRepeaterBehavior.ts | 188 +++++++-------- .../row-actions/RowActions.tsx | 39 ++- .../row-actions/RowOptionsButton.tsx | 3 +- .../row-actions/RowOptionsForm.test.tsx | 2 +- .../row-actions/RowOptionsForm.tsx | 11 +- .../row-actions/RowOptionsModal.tsx | 8 +- .../ResponsiveGridLayoutManager.tsx | 7 +- .../scene/layout-rows/RowItem.tsx | 133 +++++++++- .../RowItemRepeaterBehavior.test.tsx | 227 ++++++++++++++++++ .../layout-rows/RowItemRepeaterBehavior.ts | 165 +++++++++++++ .../scene/layout-rows/RowsLayoutManager.tsx | 59 ++++- .../features/dashboard-scene/scene/types.ts | 24 +- .../transformSaveModelSchemaV2ToScene.ts | 4 +- .../transformSaveModelToScene.test.ts | 2 +- .../transformSaveModelToScene.ts | 4 +- .../transformSceneToSaveModel.test.ts | 2 +- .../transformSceneToSaveModel.ts | 5 +- .../transformSceneToSaveModelSchemaV2.test.ts | 2 +- .../transformSceneToSaveModelSchemaV2.ts | 5 +- .../solo/useSoloPanel.test.tsx | 2 +- .../dashboard-scene/solo/useSoloPanel.ts | 5 +- .../dashboard-scene/utils/clone.test.ts | 118 +++++++++ .../features/dashboard-scene/utils/clone.ts | 73 ++++++ .../dashboard-scene/utils/test-utils.ts | 2 +- .../features/dashboard-scene/utils/utils.ts | 34 +-- public/locales/en-US/grafana.json | 38 +++ public/locales/pseudo-LOCALE/grafana.json | 38 +++ 41 files changed, 1188 insertions(+), 274 deletions(-) rename public/app/features/dashboard-scene/scene/{ => layout-default}/RowRepeaterBehavior.test.tsx (88%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/RowRepeaterBehavior.ts (62%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowActions.tsx (72%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsButton.tsx (89%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsForm.test.tsx (96%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsForm.tsx (79%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsModal.tsx (79%) create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts create mode 100644 public/app/features/dashboard-scene/utils/clone.test.ts create mode 100644 public/app/features/dashboard-scene/utils/clone.ts diff --git a/.betterer.results b/.betterer.results index ba158ab930d..160b522a8c2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3452,24 +3452,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/UnlinkModal.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], - "public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], "public/app/features/dashboard-scene/scene/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index b8fdd41352d..6376687d480 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -12,6 +12,7 @@ import { } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { ElementEditPane } from './ElementEditPane'; @@ -46,6 +47,16 @@ export class DashboardEditPane extends SceneObjectBase { } private selectElement(element: ElementSelectionContextItem, multi?: boolean) { + // We should not select clones + if (isInCloneChain(element.id)) { + if (multi) { + return; + } + + this.clearSelection(); + return; + } + const obj = sceneGraph.findByKey(this, element.id); if (obj) { this.selectObject(obj, element.id, multi); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 80d4b19fa3a..386aa1e7782 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -23,6 +23,7 @@ import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { historySrv } from '../settings/version-history/HistorySrv'; +import { getCloneKey } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; @@ -33,7 +34,7 @@ import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { PanelTimeRange } from './PanelTimeRange'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { RowActions } from './row-actions/RowActions'; +import { RowActions } from './layout-default/row-actions/RowActions'; jest.mock('../settings/version-history/HistorySrv'); jest.mock('../serialization/transformSaveModelToScene'); @@ -642,7 +643,7 @@ describe('DashboardScene', () => { it('Should hash the key of the cloned panels and set it as panelId', () => { const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner2')!; - const expectedPanelId = djb2Hash('panel-2-clone-1'); + const expectedPanelId = djb2Hash(getCloneKey('panel-2', 1)); expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId); }); }); @@ -915,7 +916,7 @@ function buildTestScene(overrides?: Partial) { new DashboardGridItem({ body: new VizPanel({ title: 'Panel B', - key: 'panel-2-clone-1', + key: getCloneKey('panel-2', 1), pluginId: 'table', $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index df925ab3272..02b179a003c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -53,17 +53,12 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { isInCloneChain } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getViewPanelUrl } from '../utils/urlBuilders'; -import { - getClosestVizPanel, - getDashboardSceneFor, - getDefaultVizPanel, - getPanelIdForVizPanel, - isPanelClone, -} from '../utils/utils'; +import { getClosestVizPanel, getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel } from '../utils/utils'; import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer'; import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer'; @@ -480,6 +475,10 @@ export class DashboardScene extends SceneObjectBase { return this._initialState; } + public getNextPanelId(): number { + return this.state.body.getMaxPanelId() + 1; + } + public addPanel(vizPanel: VizPanel): void { if (!this.state.isEditing) { this.onEnterEditMode(); @@ -606,10 +605,11 @@ export class DashboardScene extends SceneObjectBase { public switchLayout(layout: DashboardLayoutManager) { this.setState({ body: layout }); + layout.activateRepeaters?.(); } /** - * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request + * Called by the SceneQueryRunner to provide contextual parameters (tracking) props for the request */ public enrichDataRequest(sceneObject: SceneObject): Partial { const dashboard = getDashboardSceneFor(sceneObject); @@ -623,9 +623,12 @@ export class DashboardScene extends SceneObjectBase { let panelId = 0; if (panel && panel.state.key) { - if (isPanelClone(panel.state.key)) { + if (isInCloneChain(panel.state.key)) { + // We check if any of the panel ancestors are clones because we can't use the original panel ID in this case panelId = djb2Hash(panel?.state.key); } else { + // Otherwise, it's the absolute original panel, and we can use the key directly + // getPanelIdForVizPanel extracts the panel ID from the key so we don't need to do it manually panelId = getPanelIdForVizPanel(panel); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 1decb7b9fc5..6092bac64c2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -3,6 +3,8 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; +import { getCloneKey } from '../utils/clone'; + import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; @@ -82,7 +84,7 @@ describe('DashboardSceneUrlSync', () => { let errorNotice = 0; appEvents.on(AppEvents.alertError, (evt) => errorNotice++); - scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1-clone-1' }); + scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) }); expect(scene.state.viewPanelScene).toBeUndefined(); // Verify no error notice was shown @@ -98,7 +100,7 @@ describe('DashboardSceneUrlSync', () => { x: 0, body: new VizPanel({ title: 'Clone Panel A', - key: 'panel-1-clone-1', + key: getCloneKey('panel-1', 1), pluginId: 'table', }), }), @@ -107,7 +109,7 @@ describe('DashboardSceneUrlSync', () => { // Verify it subscribes to DashboardRepeatsProcessedEvent scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); - expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1-clone-1'); + expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1)); }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 960789473c1..1eb85045504 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -12,7 +12,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { containsCloneKey } from '../utils/clone'; +import { findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -94,8 +95,10 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { const panel = findVizPanelByKey(this._scene, values.viewPanel); if (!panel) { - // // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet - if (isPanelClone(values.viewPanel)) { + // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet + // Here we check if the key contains the clone key so we force the repeat processing + // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key + if (containsCloneKey(values.viewPanel)) { this._handleViewRepeatClone(values.viewPanel); return; } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 0080b57e228..1f8a9547c83 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -30,15 +30,10 @@ import { ShowConfirmModalEvent } from 'app/types/events'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; +import { isInCloneChain } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; -import { - getDashboardSceneFor, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, - isReadOnlyClone, -} from '../utils/utils'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; @@ -59,7 +54,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { const dashboard = getDashboardSceneFor(panel); const { isEmbedded } = dashboard.state.meta; const exploreMenuItem = await getExploreMenuItem(panel); - const isReadOnlyRepeat = isReadOnlyClone(panel); + const isReadOnlyRepeat = isInCloneChain(panel.state.key!); // For embedded dashboards we only have explore action for now if (isEmbedded) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index c9bf5da0031..165d00b99a2 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -3,8 +3,8 @@ import { setPluginImportUtils } from '@grafana/runtime'; import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { isInCloneChain } from '../../utils/clone'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils'; -import { isReadOnlyClone } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem, DashboardGridItemState } from './DashboardGridItem'; @@ -42,8 +42,8 @@ describe('PanelRepeaterGridItem', () => { expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A'); expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2'); - expect(isReadOnlyClone(panel1)).toBe(false); - expect(isReadOnlyClone(panel2)).toBe(true); + expect(isInCloneChain(panel1.state.key!)).toBe(false); + expect(isInCloneChain(panel2.state.key!)).toBe(true); }); it('Should wait for variable to load', async () => { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index b9ce621a2a2..165016a3493 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -23,6 +23,7 @@ import { import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils'; import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types'; @@ -138,7 +139,7 @@ export class DashboardGridItem }), ], }), - key: `${panelToRepeat.state.key}-clone-${index}`, + key: getCloneKey(panelToRepeat.state.key!, index), }; const clone = panelToRepeat.clone(cloneState); repeatedPanels.push(clone); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index 39a11c6babd..e4bb910235e 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -1,6 +1,7 @@ import { SceneGridItemLike, SceneGridLayout, SceneGridRow, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { findVizPanelByKey } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem } from './DashboardGridItem'; import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; @@ -277,5 +278,7 @@ function setup(options?: TestOptions) { const grid = new SceneGridLayout({ children: gridItems }); const manager = new DefaultGridLayoutManager({ grid: grid }); + new DashboardScene({ body: manager }); + return { manager, grid }; } 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 f6d2156b1c6..3bae6e1c50c 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -12,18 +12,21 @@ import { } from '@grafana/scenes'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; +import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { forceRenderChildren, getPanelIdForVizPanel, NEW_PANEL_HEIGHT, NEW_PANEL_WIDTH, getVizPanelKeyForPanelId, + getGridItemKeyForPanelId, + getDashboardSceneFor, } from '../../utils/utils'; -import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { RowActions } from '../row-actions/RowActions'; import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; import { DashboardGridItem } from './DashboardGridItem'; +import { RowRepeaterBehavior } from './RowRepeaterBehavior'; +import { RowActions } from './row-actions/RowActions'; interface DefaultGridLayoutManagerState extends SceneObjectState { grid: SceneGridLayout; @@ -66,7 +69,7 @@ export class DefaultGridLayoutManager x: 0, y: 0, body: vizPanel, - key: `grid-item-${panelId}`, + key: getGridItemKeyForPanelId(panelId), }); this.state.grid.setState({ @@ -75,7 +78,7 @@ export class DefaultGridLayoutManager } /** - * Adds a new emtpy row + * Adds a new empty row */ public addNewRow(): SceneGridRow { const id = this.getNextPanelId(); @@ -127,7 +130,7 @@ export class DefaultGridLayoutManager /** * Removes a panel */ - public removePanel(panel: VizPanel) { + public removePanel(panel: VizPanel): void { const gridItem = panel.parent!; if (!(gridItem instanceof DashboardGridItem)) { @@ -231,7 +234,7 @@ export class DefaultGridLayoutManager return panels; } - public getNextPanelId(): number { + public getMaxPanelId(): number { let max = 0; for (const child of this.state.grid.state.children) { @@ -271,10 +274,14 @@ export class DefaultGridLayoutManager } } - return max + 1; + return max; } - public collapseAllRows() { + public getNextPanelId(): number { + return getDashboardSceneFor(this).getNextPanelId(); + } + + public collapseAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -285,7 +292,7 @@ export class DefaultGridLayoutManager }); } - public expandAllRows() { + public expandAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -296,7 +303,7 @@ export class DefaultGridLayoutManager }); } - activateRepeaters(): void { + public activateRepeaters(): void { this.state.grid.forEachChild((child) => { if (child instanceof DashboardGridItem && !child.isActive) { child.activate(); @@ -325,6 +332,76 @@ export class DefaultGridLayoutManager return DefaultGridLayoutManager.getDescriptor(); } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + return this.clone({ + grid: this.state.grid.clone({ + isResizable: isSource && this.state.grid.state.isResizable, + isDraggable: isSource && this.state.grid.state.isDraggable, + children: this.state.grid.state.children.reduce<{ panelId: number; children: SceneGridItemLike[] }>( + (childrenAcc, child) => { + if (child instanceof DashboardGridItem) { + const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = child.clone({ + key: gridItemKey, + body: child.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(gridItem); + return childrenAcc; + } + + if (child instanceof SceneGridRow) { + const rowKey = joinCloneKeys(ancestorKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)); + + const row = child.clone({ + key: rowKey, + children: child.state.children.reduce((rowAcc, rowChild) => { + if (isClonedKey(rowChild.state.key!)) { + return rowAcc; + } + + if (!(rowChild instanceof DashboardGridItem)) { + rowAcc.push(rowChild.clone()); + return rowAcc; + } + + const gridItemKey = joinCloneKeys(rowKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = rowChild.clone({ + key: gridItemKey, + isDraggable: isSource && rowChild.state.isDraggable, + isResizable: isSource && rowChild.state.isResizable, + body: rowChild.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + }); + + rowAcc.push(gridItem); + return rowAcc; + }, []), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(row); + + return childrenAcc; + } + + childrenAcc.children.push(child.clone()); + return childrenAcc; + }, + { panelId: 0, children: [] } + ).children, + }), + }); + } + public static getDescriptor(): LayoutRegistryItem { return { name: 'Default grid', @@ -389,9 +466,15 @@ export class DefaultGridLayoutManager /** * Useful for preserving items positioning when switching layouts * @param gridItems + * @param isDraggable + * @param isResizable * @returns */ - public static fromGridItems(gridItems: SceneGridItemLike[]): DefaultGridLayoutManager { + public static fromGridItems( + gridItems: SceneGridItemLike[], + isDraggable?: boolean, + isResizable?: boolean + ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); acc.push(gridItem); @@ -402,8 +485,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children, - isDraggable: true, - isResizable: true, + isDraggable, + isResizable, }), }); } diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx similarity index 88% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx index 116fc50781d..a16bfe7da18 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx @@ -13,13 +13,13 @@ import { } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; -import { activateFullSceneTree } from '../utils/test-utils'; -import { isReadOnlyClone } from '../utils/utils'; +import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone'; +import { activateFullSceneTree } from '../../utils/test-utils'; +import { DashboardScene } from '../DashboardScene'; -import { DashboardScene } from './DashboardScene'; +import { RepeatDirection } from './DashboardGridItem'; +import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; -import { RepeatDirection } from './layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { RowActions } from './row-actions/RowActions'; jest.mock('@grafana/runtime', () => ({ @@ -54,24 +54,31 @@ describe('RowRepeaterBehavior', () => { // Verify that first row still has repeat behavior const row1 = grid.state.children[1] as SceneGridRow; + expect(row1.state.key).toBe(getCloneKey('row-1', 0)); expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1'); expect(row1.state.actions).toBeDefined(); + const gridItemRow1 = row1.state.children[0] as SceneGridItem; + expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'griditem-1')); + expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1')); + const row2 = grid.state.children[2] as SceneGridRow; + expect(row2.state.key).toBe(getCloneKey('row-1', 1)); + expect(row2.state.$behaviors).toEqual([]); expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B'); expect(row2.state.actions).toBeUndefined(); - // Should give repeated panels unique keys - const gridItem = row2.state.children[0] as SceneGridItem; - expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1'); + const gridItemRow2 = row2.state.children[0] as SceneGridItem; + expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1')); + expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1')); }); it('Repeated rows should be read only', () => { const row1 = grid.state.children[1] as SceneGridRow; const row2 = grid.state.children[2] as SceneGridRow; - expect(isReadOnlyClone(row1)).toBe(false); - expect(isReadOnlyClone(row2)).toBe(true); + expect(isInCloneChain(row1.state.key!)).toBe(false); + expect(isInCloneChain(row2.state.key!)).toBe(true); }); it('Should update all rows when a panel is added to a clone', async () => { @@ -209,6 +216,7 @@ function buildScene( const grid = new SceneGridLayout({ children: [ new SceneGridItem({ + key: 'griditem-no-row', x: 0, y: 0, width: 24, @@ -218,6 +226,7 @@ function buildScene( }), }), new SceneGridRow({ + key: 'row-1', x: 0, y: 10, width: 24, @@ -226,6 +235,7 @@ function buildScene( $behaviors: [repeatBehavior], children: [ new SceneGridItem({ + key: 'griditem-1', x: 0, y: 11, width: 24, @@ -238,12 +248,12 @@ function buildScene( ], }), new SceneGridRow({ + key: 'row-2', x: 0, y: 16, width: 24, height: 5, title: 'Row at the bottom', - children: [ new SceneGridItem({ key: 'griditem-2', diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts similarity index 62% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index 990ada92748..6ac38847198 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -14,10 +14,18 @@ import { VariableValueSingle, } from '@grafana/scenes'; -import { getMultiVariableValues } from '../utils/utils'; +import { + containsCloneKey, + getLastKeyFromClone, + isClonedKeyOf, + joinCloneKeys, + getCloneKey, + isClonedKey, +} from '../../utils/clone'; +import { getMultiVariableValues } from '../../utils/utils'; +import { DashboardRepeatsProcessedEvent } from '../types'; -import { DashboardGridItem } from './layout-default/DashboardGridItem'; -import { DashboardRepeatsProcessedEvent } from './types'; +import { DashboardGridItem } from './DashboardGridItem'; interface RowRepeaterBehaviorState extends SceneObjectState { variableName: string; @@ -47,11 +55,11 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); const sub = layout.subscribeToState(() => { - const repeatedRows = layout.state.children.filter( - (child) => child instanceof SceneGridRow && child.state.key?.includes(filterKey) + const repeatedRows = layout.state.children.filter((child) => + isClonedKeyOf(child.state.key!, originalRow.state.key!) ); // go through cloned rows, search for panels that are not clones @@ -60,24 +68,29 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); + // if no differences in row children compared to original, then no new panel added to clone - if (row.state.children.length === originalRow.state.children.length) { + if (rowNonClonedPanels.length === originalRowNonClonedPanels.length) { continue; } - //if there are differences, find the new panel, move it to the original and perform re peat - const gridItem = row.state.children.find((gridItem) => !gridItem.state.key?.includes('clone')); + // if there are differences, find the new panel, move it to the original and perform repeat + const gridItem = rowNonClonedPanels.find((gridItem) => !containsCloneKey(gridItem.state.key!)); + if (gridItem) { const newGridItem = gridItem.clone(); + row.setState({ children: row.state.children.filter((item) => item !== gridItem) }); // if we are moving a panel from the origin row to a clone row, we just return - // this means we are modifying the origin row, retriggering the repeat and losing that panel + // this means we are modifying the origin row, re-triggering the repeat and losing that panel if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) { return; } originalRow.setState({ children: [...originalRow.state.children, newGridItem] }); + this.performRepeat(true); } } @@ -135,13 +148,14 @@ export class RowRepeaterBehavior extends SceneObjectBase 0 ? `${source.state.key}-clone-${localValue}` : source.state.key; - const itemClone = source.clone({ key: itemKey, y: itemY }); + const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex); - // Make sure all the child scene objects have unique keys - // and add proper menu to the repeated panel - if (index > 0) { - ensureUniqueKeys(itemClone, localValue); - - //disallow clones to be dragged around or out of the row - if (itemClone instanceof DashboardGridItem) { - itemClone.setState({ isDraggable: false }); - } - } - - children.push(itemClone); - - if (maxYOfRows < itemY + itemClone.state.height!) { - maxYOfRows = itemY + itemClone.state.height!; - } - } - - const rowClone = this.getRowClone( - rowToRepeat, - index, - localValue, - variableTexts[index], - rowContentHeight, - children, - variable - ); - this._clonedRows.push(rowClone); - } - - updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat); - - // Used from dashboard url sync - this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); - } - - getRowClone( - rowToRepeat: SceneGridRow, - index: number, - value: VariableValueSingle, - text: VariableValueSingle, - rowContentHeight: number, - children: SceneGridItemLike[], - variable: MultiValueVariable - ): SceneGridRow { - if (index === 0) { - rowToRepeat.setState({ - // not activated + rowClone.setState({ + key: rowCloneKey, $variables: new SceneVariableSet({ variables: [ new LocalValueVariable({ name: this.state.variableName, - value, - text: String(text), + value: variableValues[rowIndex], + text: String(variableTexts[rowIndex]), isMulti: variable.state.isMulti, includeAll: variable.state.includeAll, }), ], }), - children, + children: [], }); - return rowToRepeat; + + const children: SceneGridItemLike[] = []; + + for (const sourceItem of rowContent) { + const sourceItemY = sourceItem.state.y ?? 0; + + const cloneItemKey = joinCloneKeys(rowCloneKey, getLastKeyFromClone(sourceItem.state.key!)); + const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex; + + const cloneItem = sourceItem.clone({ + key: cloneItemKey, + y: cloneItemY, + isDraggable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isDraggable, + isResizable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isResizable, + }); + + ensureUniqueKeys(cloneItem, cloneItemKey); + + children.push(cloneItem); + + if (maxYOfRows < cloneItemY + cloneItem.state.height!) { + maxYOfRows = cloneItemY + cloneItem.state.height!; + } + } + + rowClone.setState({ children }); + + this._clonedRows.push(rowClone); } - const sourceRowY = rowToRepeat.state.y ?? 0; + updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat.state.key!); - return rowToRepeat.clone({ - key: `${rowToRepeat.state.key}-clone-${value}`, - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: this.state.variableName, - value, - text: String(text), - isMulti: variable.state.isMulti, - includeAll: variable.state.includeAll, - }), - ], - }), - $behaviors: [], - children, - y: sourceRowY + rowContentHeight * index + index, - actions: undefined, - }); + // Used from dashboard url sync + this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } public removeBehavior() { const row = this._getRow(); const layout = this._getLayout(); - const children = getLayoutChildrenFilterOutRepeatClones(this._getLayout(), this._getRow()); + const children = getLayoutChildrenFilterOutRepeatClones(layout, row.state.key!); layout.setState({ children: children }); @@ -280,9 +259,9 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number { return maxY - minY; } -function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowToRepeat: SceneGridRow) { - const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowToRepeat); - const index = allChildren.indexOf(rowToRepeat); +function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) { + const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey); + const index = allChildren.findIndex((child) => child.state.key!.includes(rowKey)); if (index === -1) { throw new Error('RowRepeaterBehavior: Parent row not found in layout children'); @@ -310,19 +289,14 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: layout.setState({ children: newChildren }); } -function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowToRepeat: SceneGridRow) { - return layout.state.children.filter((child) => { - if (child.state.key?.startsWith(`${rowToRepeat.state.key}-clone-`)) { - return false; - } - - return true; - }); +function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) { + return layout.state.children.filter((child) => !isClonedKeyOf(child.state.key!, rowKey)); } -function ensureUniqueKeys(item: SceneGridItemLike, localValue: VariableValueSingle) { +function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) { item.forEachChild((child) => { - child.setState({ key: `${child.state.key}-clone-${localValue}` }); - ensureUniqueKeys(child, localValue); + const key = joinCloneKeys(ancestors, child.state.key!); + child.setState({ key }); + ensureUniqueKeys(child, key); }); } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx similarity index 72% rename from public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx index 5d5f342dca2..c26258176db 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx @@ -11,14 +11,16 @@ import { } from '@grafana/scenes'; import { Icon, TextLink, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { t, Trans } from 'app/core/internationalization'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils'; -import { DashboardScene } from '../DashboardScene'; +import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils'; +import { DashboardScene } from '../../DashboardScene'; +import { DashboardGridItem } from '../DashboardGridItem'; +import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { DashboardGridItem } from '../layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowOptionsButton } from './RowOptionsButton'; @@ -75,9 +77,12 @@ export class RowActions extends SceneObjectBase { public onDelete = () => { appEvents.publish( new ShowConfirmModalEvent({ - title: 'Delete row', - text: 'Are you sure you want to remove this row and all its panels?', - altActionText: 'Delete row only', + title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'), + text: t( + 'dashboard.default-layout.row-actions.modal.text', + 'Are you sure you want to remove this row and all its panels?' + ), + altActionText: t('dashboard.default-layout.row-actions.modal.alt-action', 'Delete row only'), icon: 'trash-alt', onConfirm: () => this.removeRow(true), onAltAction: () => this.removeRow(), @@ -97,7 +102,11 @@ export class RowActions extends SceneObjectBase { const vizPanel = gridItem.state.body; if (vizPanel instanceof VizPanel) { const runner = getQueryRunnerFor(vizPanel); - return runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY; + return ( + runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY || + (runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME && + runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY)) + ); } return false; @@ -107,8 +116,10 @@ export class RowActions extends SceneObjectBase { return (

- Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in - the original row, not the ones in the repeated rows. + + Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel + in the original row, not the ones in the repeated rows. +

{ 'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows' } > - Learn more + Learn more
); @@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase { onUpdate={model.onUpdate} warning={model.getWarning()} /> - diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx similarity index 89% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx index 546c7de8387..aca11f93bbc 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Icon, ModalsController } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate } from './RowOptionsForm'; import { RowOptionsModal } from './RowOptionsModal'; @@ -27,7 +28,7 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R + - ); diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx similarity index 79% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx index bab4052178b..0976bf2e387 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Modal, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; @@ -19,7 +20,12 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa const styles = useStyles2(getStyles); return ( - + child !== element) }); @@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager activateRepeaters?(): void { throw new Error('Method not implemented.'); } + public static Component = ({ model }: SceneComponentProps) => { return ; }; 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 cd95834bcad..e52fd8d46d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -1,17 +1,40 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef } from 'react'; +import { ReactNode, useMemo, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { Button, Icon, Input, RadioButtonGroup, Switch, useElementSelection, useStyles2 } from '@grafana/ui'; +import { + SceneObjectState, + SceneObjectBase, + SceneComponentProps, + sceneGraph, + VariableDependencyConfig, +} from '@grafana/scenes'; +import { + Alert, + Button, + Icon, + Input, + RadioButtonGroup, + Switch, + TextLink, + useElementSelection, + useStyles2, +} from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; +import { isClonedKey } from '../../utils/clone'; +import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types'; +import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; import { RowsLayoutManager } from './RowsLayoutManager'; export interface RowItemState extends SceneObjectState { @@ -23,6 +46,10 @@ export interface RowItemState extends SceneObjectState { } export class RowItem extends SceneObjectBase implements LayoutParent, EditableDashboardElement { + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['title'], + }); + public isEditableDashboardElement: true = true; public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { @@ -54,10 +81,25 @@ export class RowItem extends SceneObjectBase implements LayoutPare ); }, [row]); + const rowRepeatOptions = useMemo(() => { + const dashboard = getDashboardSceneFor(row); + + return new OptionsPaneCategoryDescriptor({ + title: 'Repeat options', + id: 'row-repeat-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: 'Variable', + render: () => , + }) + ); + }, [row]); + const { layout } = this.useState(); const layoutOptions = useLayoutCategory(layout); - return [rowOptions, layoutOptions]; + return [rowOptions, rowRepeatOptions, layoutOptions]; } public getTypeName(): string { @@ -69,7 +111,7 @@ export class RowItem extends SceneObjectBase implements LayoutPare layout.removeRow(this); }; - public renderActions(): React.ReactNode { + public renderActions(): ReactNode { return ( <> - {isEditing && ( + {!isClone && isEditing && (