Dashboards: Implement rows repeat in rows layout (#99300)

This commit is contained in:
Bogdan Matei 2025-02-03 11:46:47 +02:00 committed by GitHub
parent 8c0b812874
commit 61f5f215ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1188 additions and 274 deletions

View File

@ -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 <Trans /> 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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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"]

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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<DashboardEditPaneState> {
}
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);

View File

@ -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<DashboardSceneState>) {
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' }] }),
}),

View File

@ -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<DashboardSceneState> {
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<DashboardSceneState> {
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<DataQueryRequest> {
const dashboard = getDashboardSceneFor(sceneObject);
@ -623,9 +623,12 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
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);
}
}

View File

@ -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));
});
});

View File

@ -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;
}

View File

@ -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) {

View File

@ -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 () => {

View File

@ -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);

View File

@ -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 };
}

View File

@ -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<SceneGridItemLike[]>((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<SceneGridItemLike[]>((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,
}),
});
}

View File

@ -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',

View File

@ -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<RowRepeaterBehaviorStat
const layout = this._getLayout();
const originalRow = this._getRow();
const filterKey = originalRow.state.key + '-clone-';
const originalRowNonClonedPanels = originalRow.state.children.filter((child) => !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<RowRepeaterBehaviorStat
continue;
}
const rowNonClonedPanels = row.state.children.filter((child) => !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<RowRepeaterBehaviorStat
this._prevRepeatValues = values;
this._clonedRows = [];
const rowContent = rowToRepeat.state.children;
const rowContentHeight = getRowContentHeight(rowContent);
let maxYOfRows = 0;
// 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
// 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'],
@ -150,108 +164,73 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
// Loop through variable values and create repeates
for (let index = 0; index < variableValues.length; index++) {
const children: SceneGridItemLike[] = [];
const localValue = variableValues[index];
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
const isSourceRow = rowIndex === 0;
const rowClone = isSourceRow
? rowToRepeat
: rowToRepeat.clone({
y: (rowToRepeat.state.y ?? 0) + rowContentHeight * rowIndex + rowIndex,
$behaviors: [],
actions: undefined,
});
// Loop through panels inside row
for (const source of rowContent) {
const sourceItemY = source.state.y ?? 0;
const itemY = sourceItemY + (rowContentHeight + 1) * index;
const itemKey = index > 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);
});
}

View File

@ -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<RowActionsState> {
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<RowActionsState> {
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<RowActionsState> {
return (
<div>
<p>
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.
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.text">
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.
</Trans>
</p>
<TextLink
external
@ -116,7 +127,7 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
Learn more
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.learn-more">Learn more</Trans>
</TextLink>
</div>
);
@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
onUpdate={model.onUpdate}
warning={model.getWarning()}
/>
<button type="button" onClick={model.onDelete} aria-label="Delete row">
<button
type="button"
onClick={model.onDelete}
aria-label={t('dashboard.default-layout.row-actions.delete', 'Delete row')}
>
<Icon name="trash-alt" />
</button>
</div>

View File

@ -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
<button
type="button"
className="pointer"
aria-label="Row options"
aria-label={t('dashboard.default-layout.row-options.button.label', 'Row options')}
onClick={() => {
showModal(RowOptionsModal, {
title,

View File

@ -3,7 +3,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardScene } from '../DashboardScene';
import { DashboardScene } from '../../DashboardScene';
import { RowOptionsForm } from './RowOptionsForm';

View File

@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObject } from '@grafana/scenes';
import { Button, Field, Modal, Input, Alert } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
@ -34,10 +35,10 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
return (
<form onSubmit={handleSubmit(submit)}>
<Field label="Title">
<Field label={t('dashboard.default-layout.row-options.form.title', 'Title')}>
<Input {...register('title')} type="text" />
</Field>
<Field label="Repeat for">
<Field label={t('dashboard.default-layout.row-options.form.repeat-for', 'Repeat for')}>
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
</Field>
{warning && (
@ -53,9 +54,11 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
)}
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
<Trans i18nKey="dashboard.default-layout.row-options.form.cancel">Cancel</Trans>
</Button>
<Button type="submit">
<Trans i18nKey="dashboard.default-layout.row-options.form.update">Update</Trans>
</Button>
<Button type="submit">Update</Button>
</Modal.ButtonRow>
</form>
);

View File

@ -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 (
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}>
<Modal
isOpen={true}
title={t('dashboard.default-layout.row-options.modal.title', 'Row options')}
onDismiss={onDismiss}
className={styles.modal}
>
<RowOptionsForm
sceneContext={parent}
repeat={repeat}

View File

@ -38,7 +38,7 @@ export class ResponsiveGridLayoutManager
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public getNextPanelId(): number {
public getMaxPanelId(): number {
let max = 0;
for (const child of this.state.layout.state.children) {
@ -54,6 +54,10 @@ export class ResponsiveGridLayoutManager
return max;
}
public getNextPanelId(): number {
return getDashboardSceneFor(this).getNextPanelId();
}
public removePanel(panel: VizPanel) {
const element = panel.parent;
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager
activateRepeaters?(): void {
throw new Error('Method not implemented.');
}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return <model.state.layout.Component model={model.state.layout} />;
};

View File

@ -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<RowItemState> 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<RowItemState> 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: () => <RowRepeatSelect row={row} dashboard={dashboard} />,
})
);
}, [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<RowItemState> implements LayoutPare
layout.removeRow(this);
};
public renderActions(): React.ReactNode {
public renderActions(): ReactNode {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
@ -91,12 +133,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
};
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
this.state.layout.addPanel(vizPanel);
this.getLayout().addPanel(vizPanel);
};
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
const { isEditing, showHiddenElements } = getDashboardSceneFor(model).useState();
const isClone = useMemo(() => isClonedKey(key!), [key]);
const dashboard = getDashboardSceneFor(model);
const { isEditing, showHiddenElements } = dashboard.useState();
const styles = useStyles2(getStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
@ -109,7 +153,7 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
styles.wrapper,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
isSelected && 'dashboard-selected-element'
!isClone && isSelected && 'dashboard-selected-element'
)}
ref={ref}
>
@ -119,14 +163,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
onClick={model.onCollapseToggle}
className={styles.rowTitleButton}
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'}
data-testid={selectors.components.DashboardRow.title(titleInterpolated)}
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{isEditing && (
{!isClone && isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
</div>
@ -181,6 +225,7 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
width: '100%',
minHeight: '100px',
}),
wrapperGrow: css({
flexGrow: 1,
@ -188,6 +233,7 @@ function getStyles(theme: GrafanaTheme2) {
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
minHeight: 'unset',
}),
rowActions: css({
display: 'flex',
@ -237,3 +283,68 @@ export function RowHeightSelect({ row }: { row: RowItem }) {
/>
);
}
export function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
const { layout, $behaviors } = row.useState();
let repeatBehavior: RowItemRepeaterBehavior | undefined = $behaviors?.find(
(b) => b instanceof RowItemRepeaterBehavior
);
const { variableName } = repeatBehavior?.state ?? {};
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
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 (
<>
<RepeatRowSelect2
sceneContext={dashboard}
repeat={variableName}
onChange={(repeat) => {
if (repeat) {
// Remove repeat behavior if it exists to trigger repeat when adding new one
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
row.setState({ $behaviors: [...(row.state.$behaviors ?? []), repeatBehavior] });
repeatBehavior.activate();
} else {
repeatBehavior?.removeBehavior();
}
}}
/>
{isAnyPanelUsingDashboardDS ? (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
title=""
topSpacing={3}
bottomSpacing={0}
>
<p>
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
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.
</Trans>
</p>
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
</TextLink>
</Alert>
) : undefined}
</>
);
}

View File

@ -0,0 +1,227 @@
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
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 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: 'griditem-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: 'griditem-2',
x: 0,
y: 17,
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
}),
new DashboardGridItem({
key: 'griditem-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;
}

View File

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

View File

@ -12,13 +12,16 @@ import {
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { isClonedKey } from '../../utils/clone';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
@ -58,6 +61,10 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
});
}
public getMaxPanelId(): number {
return Math.max(...this.state.rows.map((row) => row.getLayout().getMaxPanelId()));
}
public getNextPanelId(): number {
return 0;
}
@ -78,7 +85,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
const panels: VizPanel[] = [];
for (const row of this.state.rows) {
const innerPanels = row.state.layout.getVizPanels();
const innerPanels = row.getLayout().getVizPanels();
panels.push(...innerPanels);
}
@ -89,6 +96,23 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return [];
}
public activateRepeaters() {
this.state.rows.forEach((row) => {
if (row.state.$behaviors) {
for (const behavior of row.state.$behaviors) {
if (behavior instanceof RowItemRepeaterBehavior && !row.isActive) {
row.activate();
break;
}
}
if (!row.getLayout().isActive) {
row.getLayout().activate();
}
}
});
}
public getDescriptor(): LayoutRegistryItem {
return RowsLayoutManager.getDescriptor();
}
@ -111,11 +135,16 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
let rows: RowItem[];
if (layout instanceof DefaultGridLayoutManager) {
const config: Array<{
title?: string;
isCollapsed?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
children: SceneGridItemLike[];
repeat?: string;
}> = [];
let children: SceneGridItemLike[] | undefined;
@ -125,12 +154,19 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
if (child instanceof SceneGridRow) {
if (!child.state.key?.includes('-clone-')) {
if (!isClonedKey(child.state.key!)) {
const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
config.push({
title: child.state.title,
isCollapsed: !!child.state.isCollapsed,
isDraggable: child.state.isDraggable ?? layout.state.grid.state.isDraggable,
isResizable: child.state.isResizable ?? layout.state.grid.state.isResizable,
children: child.state.children,
repeat: behaviour?.state.variableName,
});
// Since we encountered a row item, any subsequent panels should be added to a new row
children = undefined;
}
} else {
@ -143,21 +179,24 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
});
const rows = config.map(
rows = config.map(
(rowConfig) =>
new RowItem({
title: rowConfig.title ?? 'Row title',
isCollapsed: !!rowConfig.isCollapsed,
layout: DefaultGridLayoutManager.fromGridItems(rowConfig.children),
layout: DefaultGridLayoutManager.fromGridItems(
rowConfig.children,
rowConfig.isDraggable,
rowConfig.isResizable
),
$behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [],
})
);
return new RowsLayoutManager({ rows });
} else {
rows = [new RowItem({ layout: layout.clone(), title: 'Row title' })];
}
const row = new RowItem({ layout: layout.clone(), title: 'Row title' });
return new RowsLayoutManager({ rows: [row] });
return new RowsLayoutManager({ rows });
}
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
@ -167,7 +206,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<RowItem.Component model={row} key={row.state.key!} />
<row.Component model={row} key={row.state.key!} />
))}
</div>
);

View File

@ -10,50 +10,72 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
export interface DashboardLayoutManager extends SceneObject {
/** Marks it as a DashboardLayoutManager */
isDashboardLayoutManager: true;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
*/
editModeChanged(isEditing: boolean): void;
/**
* Remove an element / panel
* @param element
*/
removePanel(panel: VizPanel): void;
/**
* Creates a copy of an existing element and adds it to the layout
* @param element
*/
duplicatePanel(panel: VizPanel): void;
/**
* Adds a new panel to the layout
*/
addPanel(panel: VizPanel): void;
/**
* Add row
*/
addNewRow(): void;
/**
* getVizPanels
*/
getVizPanels(): VizPanel[];
/**
* Turn into a save model
* @param saveModel
*/
toSaveModel?(): any;
/**
* For dynamic panels that need to be viewed in isolation (SoloRoute)
*/
activateRepeaters?(): void;
/**
* Get's the layout descriptor (which has the name and id)
* Gets the layout descriptor (which has the name and id)
*/
getDescriptor(): LayoutRegistryItem;
/**
* Renders options and layout actions
*/
getOptions?(): OptionsPaneItemDescriptor[];
/**
* Create a clone of the layout manager given an ancestor key
* @param ancestorKey
* @param isSource
*/
cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager;
/**
* Returns the highest panel id in the layout
*/
getMaxPanelId(): number;
}
export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager {

View File

@ -81,11 +81,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowActions } from '../scene/row-actions/RowActions';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';

View File

@ -30,9 +30,9 @@ import { DashboardDataDTO } from 'app/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils';

View File

@ -41,11 +41,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowActions } from '../scene/row-actions/RowActions';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';

View File

@ -25,9 +25,9 @@ import { DashboardDataDTO } from 'app/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils';
import { getVizPanelKeyForPanelId } from '../utils/utils';

View File

@ -33,9 +33,10 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
calculateGridItemDimensions,
@ -72,7 +73,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
if (child instanceof SceneGridRow) {
// Skip repeat clones or when generating a snapshot
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
continue;
}
gridRowToSaveModel(child, panels, isSnapshot);

View File

@ -30,9 +30,9 @@ import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';

View File

@ -47,9 +47,10 @@ import {
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
getLibraryPanelBehavior,
@ -174,7 +175,7 @@ function getGridLayoutItems(
elements.push(gridItemToGridLayoutItemKind(child, isSnapshot));
}
} else if (child instanceof SceneGridRow) {
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
// Skip repeat rows
continue;
}

View File

@ -46,7 +46,7 @@ describe('useSoloPanel', () => {
it('should return the cloned panel when panel is found', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1_clone'));
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1-clone-1'));
const panel = findVizPanelByKey(dashboard, 'panel-1');
expect(result.current[0]).not.toBe(panel);

View File

@ -4,7 +4,8 @@ import { VizPanel, UrlSyncManager } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardRepeatsProcessedEvent } from '../scene/types';
import { findVizPanelByKey, isPanelClone } from '../utils/utils';
import { containsCloneKey } from '../utils/clone';
import { findVizPanelByKey } from '../utils/utils';
export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] {
const [panel, setPanel] = useState<VizPanel>();
@ -26,7 +27,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
if (panel) {
activateParents(panel);
setPanel(panel);
} else if (isPanelClone(panelId)) {
} else if (containsCloneKey(panelId)) {
findRepeatClone(dashboard, panelId).then((panel) => {
if (panel) {
setPanel(panel);

View File

@ -0,0 +1,118 @@
import {
getCloneKey,
getOriginalKey,
isInCloneChain,
isClonedKey,
joinCloneKeys,
containsCloneKey,
getLastKeyFromClone,
isClonedKeyOf,
} from './clone';
describe('clone', () => {
describe('getCloneKey', () => {
it('should return the clone key', () => {
expect(getCloneKey('panel', 1)).toBe('panel-clone-1');
expect(getCloneKey('panel-clone-2', 1)).toBe('panel-clone-1');
});
it('should not alter ancestors', () => {
expect(getCloneKey('row-clone-1/panel', 2)).toBe('row-clone-1/panel-clone-2');
expect(getCloneKey('tab-clone-0/row-clone-1/panel', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
expect(getCloneKey('row-clone-1/panel-clone-3', 2)).toBe('row-clone-1/panel-clone-2');
expect(getCloneKey('tab-clone-0/row-clone-1/panel-clone-3', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
});
});
describe('getOriginalKey', () => {
it('should return the original key', () => {
expect(getOriginalKey('panel')).toBe('panel');
expect(getOriginalKey('panel-clone-1')).toBe('panel');
expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel');
expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel');
});
});
describe('isClonedKey', () => {
it('should return true for cloned keys', () => {
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
expect(isClonedKey('row-clone-0/panel-clone-1')).toBe(true);
expect(isClonedKey('panel-clone-1')).toBe(true);
});
it('should return false for non-cloned keys', () => {
expect(isClonedKey('panel-clone-0')).toBe(false);
expect(isClonedKey('tab-clone-1/row-clone-2/panel-clone-0')).toBe(false);
expect(isClonedKey('row-clone-1/panel-clone-0')).toBe(false);
expect(isClonedKey('panel')).toBe(false);
expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false);
expect(isClonedKey('row-clone-1/panel')).toBe(false);
});
});
describe('isClonedKeyOf', () => {
it('should return true for cloned keys', () => {
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel-clone-2')).toBe(true);
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel')).toBe(true);
expect(isClonedKeyOf('panel-clone-2', 'panel-clone-2')).toBe(true);
expect(isClonedKeyOf('panel-clone-2', 'panel')).toBe(true);
});
it('should return false for non-cloned keys', () => {
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2-clone-2')).toBe(false);
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2')).toBe(false);
expect(isClonedKeyOf('panel-clone-2', 'panel2-clone-2')).toBe(false);
expect(isClonedKeyOf('panel-clone-2', 'panel2')).toBe(false);
});
});
describe('isInCloneChain', () => {
it('should return true for keys with cloned ancestors', () => {
expect(isInCloneChain('tab-clone-1/row-clone-0/panel-clone-0')).toBe(true);
expect(isInCloneChain('row-clone-0/row-clone-1/panel-clone-0')).toBe(true);
expect(isInCloneChain('row-clone-0/row-clone-0/panel-clone-1')).toBe(true);
expect(isInCloneChain('panel-clone-1')).toBe(true);
});
it('should return false for keys without cloned ancestors', () => {
expect(isInCloneChain('panel-clone-0')).toBe(false);
expect(isInCloneChain('row-clone-0/panel-clone-0')).toBe(false);
expect(isInCloneChain('tab-clone-0/row-clone-0/panel-clone-0')).toBe(false);
expect(isInCloneChain('panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row-clone-0/panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row/panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row/panel-0')).toBe(false);
expect(isInCloneChain('tab/row-clone-0/panel-0')).toBe(false);
expect(isInCloneChain('row-clone-0/panel')).toBe(false);
});
});
describe('getLastKeyFromClone', () => {
it('should return the last key', () => {
expect(getLastKeyFromClone('tab-clone-1/row-clone-2/panel-clone-3')).toBe('panel-clone-3');
expect(getLastKeyFromClone('row-clone-1/panel-clone-2')).toBe('panel-clone-2');
expect(getLastKeyFromClone('row-clone-1/panel')).toBe('panel');
expect(getLastKeyFromClone('panel')).toBe('panel');
});
});
describe('joinCloneKeys', () => {
it('should join keys with a separator', () => {
expect(joinCloneKeys('row', 'panel-clone-1')).toBe('row/panel-clone-1');
});
});
describe('containsCloneKey', () => {
it('should return true for keys with clone key', () => {
expect(containsCloneKey('row-clone-0/panel-clone-1')).toBe(true);
expect(containsCloneKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
expect(containsCloneKey('panel-clone-1')).toBe(true);
});
it('should return false for keys without clone key', () => {
expect(containsCloneKey('panel')).toBe(false);
expect(containsCloneKey('tab-0/row-1/panel-2')).toBe(false);
expect(containsCloneKey('row-1/panel-2')).toBe(false);
});
});
});

View File

@ -0,0 +1,73 @@
const CLONE_KEY = '-clone-';
const CLONE_SEPARATOR = '/';
const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`);
const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
/**
* Create or alter the last key for a key
* @param key
* @param index
*/
export function getCloneKey(key: string, index: number): string {
const parts = key.split(CLONE_SEPARATOR).slice(0, -1);
const lastKey = getOriginalKey(getLastKeyFromClone(key));
return [...parts, `${lastKey}${CLONE_KEY}${index}`].join(CLONE_SEPARATOR);
}
/**
* Get the original key from a clone key
* @param key
*/
export function getOriginalKey(key: string): string {
return getLastKeyFromClone(key).replace(ORIGINAL_REGEX, '');
}
/**
* Checks if the last key is a clone key
* @param key
*/
export function isClonedKey(key: string): boolean {
return CLONED_KEY_REGEX.test(getLastKeyFromClone(key));
}
/**
* Checks if key1 is a clone of key2
* @param key1
* @param key2
*/
export function isClonedKeyOf(key1: string, key2: string): boolean {
return isClonedKey(key1) && getOriginalKey(key1) === getOriginalKey(key2);
}
/**
* Checks if the key or any of its ancestors are cloned
* @param key
*/
export function isInCloneChain(key: string): boolean {
return key.split(CLONE_SEPARATOR).some(isClonedKey);
}
/**
* Get the last key from a clone key
* @param key
*/
export function getLastKeyFromClone(key: string): string {
return key.split(CLONE_SEPARATOR).pop() ?? '';
}
/**
* Join clone keys
* @param keys
*/
export function joinCloneKeys(...keys: string[]): string {
return keys.filter(Boolean).join(CLONE_SEPARATOR);
}
/**
* Checks if a key contains the '-clone-' string
* @param key
*/
export function containsCloneKey(key: string): boolean {
return key.includes(CLONE_KEY);
}

View File

@ -17,9 +17,9 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/co
import { DashboardDTO } from 'app/types';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);

View File

@ -21,6 +21,8 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
import { getLastKeyFromClone, getOriginalKey } from './clone';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@ -29,7 +31,7 @@ export function getVizPanelKeyForPanelId(panelId: number) {
}
export function getPanelIdForVizPanel(panel: SceneObject): number {
return parseInt(panel.state.key!.replace('panel-', ''), 10);
return parseInt(getOriginalKey(panel.state.key!).replace('panel-', ''), 10);
}
/**
@ -62,7 +64,7 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
if (objKey === key) {
if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) {
return true;
}
@ -212,30 +214,6 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null {
return null;
}
export function isPanelClone(key: string) {
return key.includes('clone');
}
/**
* Recursivly check the scene graph up until it finds a read only clone.
* If the key contains clone-0 it is the reference object and can be edited
*/
export function isReadOnlyClone(sceneObject: SceneObject): boolean {
const key = sceneObject.state.key!;
// Regular expression to match 'clone-' followed by a number, but not 'clone-0' as the is the reference object
const pattern = /clone-(?!0)/;
if (pattern.test(key)) {
return true;
}
if (sceneObject.parent) {
return isReadOnlyClone(sceneObject.parent);
}
return false;
}
export function getDefaultVizPanel(): VizPanel {
return new VizPanel({
title: 'Panel Title',
@ -351,3 +329,7 @@ export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutMa
throw new Error('Could not find layout manager for scene object');
}
export function getGridItemKeyForPanelId(panelId: number): string {
return `grid-item-${panelId}`;
}

View File

@ -882,6 +882,36 @@
"redirect-link": "List in Grafana Alerting",
"subtitle": "Alert rules related to this dashboard"
},
"default-layout": {
"row-actions": {
"delete": "Delete row",
"modal": {
"alt-action": "Delete row only",
"text": "Are you sure you want to remove this row and all its panels?",
"title": "Delete row"
},
"repeat": {
"warning": {
"learn-more": "Learn more",
"text": "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."
}
}
},
"row-options": {
"button": {
"label": "Row options"
},
"form": {
"cancel": "Cancel",
"repeat-for": "Repeat for",
"title": "Title",
"update": "Update"
},
"modal": {
"title": "Row options"
}
}
},
"empty": {
"add-library-panel-body": "Add visualizations that are shared with other dashboards.",
"add-library-panel-button": "Add library panel",
@ -954,6 +984,14 @@
"no-rules": "There are no alert rules linked to this panel."
}
},
"rows-layout": {
"row": {
"repeat": {
"learn-more": "Learn more",
"warning": "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."
}
}
},
"toolbar": {
"add": "Add",
"alert-rules": "Alert rules",

View File

@ -882,6 +882,36 @@
"redirect-link": "Ŀįşŧ įʼn Ğřäƒäʼnä Åľęřŧįʼnģ",
"subtitle": "Åľęřŧ řūľęş řęľäŧęđ ŧő ŧĥįş đäşĥþőäřđ"
},
"default-layout": {
"row-actions": {
"delete": "Đęľęŧę řőŵ",
"modal": {
"alt-action": "Đęľęŧę řőŵ őʼnľy",
"text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęmővę ŧĥįş řőŵ äʼnđ äľľ įŧş päʼnęľş?",
"title": "Đęľęŧę řőŵ"
},
"repeat": {
"warning": {
"learn-more": "Ŀęäřʼn mőřę",
"text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
}
}
},
"row-options": {
"button": {
"label": "Ŗőŵ őpŧįőʼnş"
},
"form": {
"cancel": "Cäʼnčęľ",
"repeat-for": "Ŗępęäŧ ƒőř",
"title": "Ŧįŧľę",
"update": "Ůpđäŧę"
},
"modal": {
"title": "Ŗőŵ őpŧįőʼnş"
}
}
},
"empty": {
"add-library-panel-body": "Åđđ vįşūäľįžäŧįőʼnş ŧĥäŧ äřę şĥäřęđ ŵįŧĥ őŧĥęř đäşĥþőäřđş.",
"add-library-panel-button": "Åđđ ľįþřäřy päʼnęľ",
@ -954,6 +984,14 @@
"no-rules": "Ŧĥęřę äřę ʼnő äľęřŧ řūľęş ľįʼnĸęđ ŧő ŧĥįş päʼnęľ."
}
},
"rows-layout": {
"row": {
"repeat": {
"learn-more": "Ŀęäřʼn mőřę",
"warning": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
}
}
},
"toolbar": {
"add": "Åđđ",
"alert-rules": "Åľęřŧ řūľęş",