Dashboards: Repeating with no clone keys (#109839)

* Dashboards: New pathId concept to find panels not based on scene object key

* Minor fix

* fix test

* Fix test

* adding unit tests for pathId functions

* fix

* Fix link sharing to use new path

* fix

* Update

* No clone keys

* Remove all the clone keys complexity

* More changes

* update e2e test

* Progress

* fix auto grid item

* Finally working cannot move panel into clone row

* adjust how we find vizpanel for keyboard shortcuts

* Update

* Fix shortcuts

* Fix test

* fixing tests

* fix lint

* fix tests

* fix e2e

* adjust how outliine children are collected for auto and custom grids

* update

* Upgrade scenes

---------

Co-authored-by: oscarkilhed <oscar.kilhed@grafana.com>
Co-authored-by: Sergej-Vlasov <sergej.s.vlasov@gmail.com>
This commit is contained in:
Torkel Ödegaard
2025-08-20 10:21:18 +02:00
committed by GitHub
co-authored by oscarkilhed Sergej-Vlasov
parent f6404b9589
commit 485831f0b2
56 changed files with 384 additions and 744 deletions
@@ -35,12 +35,12 @@ test.describe(
test('Can view solo repeated panel in scenes', async ({ page, selectors }) => {
// open Panel Tests - Graph NG
const soloPanelUrl = selectors.pages.SoloPanel.url(
'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=A$panel-2&__feature.dashboardSceneSolo=true'
);
await page.goto(soloPanelUrl);
// Check that the panel title exists
const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=B'));
const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=A'));
await expect(panelTitle).toBeVisible();
// Check that uplot-main-div does not exist
@@ -51,7 +51,7 @@ test.describe(
test('Can view solo in repeated row and panel in scenes', async ({ page, selectors }) => {
// open Panel Tests - Graph NG
const soloPanelUrl = selectors.pages.SoloPanel.url(
'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'
'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=B$2$panel-2&__feature.dashboardSceneSolo=true'
);
await page.goto(soloPanelUrl);
@@ -25,17 +25,17 @@ 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=A$panel-2&__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 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-16-clone-1/grid-item-2/panel-2-clone-1&__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=B$2$panel-2&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
+3 -3
View File
@@ -25,17 +25,17 @@ 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=A$panel-2&__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 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-16-clone-1/grid-item-2/panel-2-clone-1&__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=B$2$panel-2&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
+2 -2
View File
@@ -290,8 +290,8 @@
"@grafana/plugin-ui": "0.10.9",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.29.7",
"@grafana/scenes-react": "6.29.7",
"@grafana/scenes": "^6.30.0",
"@grafana/scenes-react": "^6.30.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+1 -1
View File
@@ -99,7 +99,7 @@ export const getShareUrlParams = (
const urlParamsUpdate: UrlQueryMap = {};
if (panel) {
urlParamsUpdate.viewPanel = panel.state.key;
urlParamsUpdate.viewPanel = panel.getPathId();
}
if (opts.useAbsoluteTimeRange) {
@@ -6,8 +6,8 @@ import {
} from '@grafana/ui';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { containsCloneKey, getLastKeyFromClone, isInCloneChain } from '../utils/clone';
import { findEditPanel, getDashboardSceneFor } from '../utils/utils';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils';
import { ElementSelection } from './ElementSelection';
import {
@@ -183,24 +183,13 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
}
private selectElement(element: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) {
// We should not select clones
if (isInCloneChain(element.id)) {
if (options.multi) {
let obj = sceneGraph.findByKey(this, element.id);
if (obj) {
// Do not select repeat clones or their children
if (isRepeatCloneOrChildOf(obj)) {
return;
}
this.clearSelection();
return;
}
let obj = sceneGraph.findByKey(this, element.id);
if (obj) {
if (obj instanceof VizPanel && containsCloneKey(getLastKeyFromClone(element.id))) {
const sourceVizPanel = findEditPanel(this, element.id);
if (sourceVizPanel) {
obj = sourceVizPanel;
}
}
this.selectObject(obj, element.id, options);
}
}
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { isInCloneChain } from '../utils/clone';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditPane } from './DashboardEditPane';
@@ -39,7 +39,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
const { key } = sceneObject.useState();
const [isCollapsed, setIsCollapsed] = useState(depth > 0);
const { isSelected, onSelect } = useElementSelection(key);
const isCloned = useMemo(() => isInCloneChain(key!), [key]);
const isCloned = useMemo(() => isRepeatCloneOrChildOf(sceneObject), [sceneObject]);
const editableElement = useMemo(() => getEditableElementFor(sceneObject)!, [sceneObject]);
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
@@ -5,7 +5,7 @@ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constan
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import {
findOriginalVizPanelByKey,
findVizPanelByKey,
getDashboardSceneFor,
getLibraryPanelBehavior,
getQueryRunnerFor,
@@ -54,7 +54,7 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatas
const panelId = dashboardQuery.panelId;
const vizKey = getVizPanelKeyForPanelId(panelId);
// We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones
const sourcePanel = findOriginalVizPanelByKey(dashboard, vizKey);
const sourcePanel = findVizPanelByKey(dashboard, vizKey);
if (!(sourcePanel instanceof VizPanel)) {
return;
@@ -19,6 +19,7 @@ import {
SceneGridRow,
behaviors,
SceneDataTransformer,
LocalValueVariable,
} from '@grafana/scenes';
import { Dashboard, DashboardCursorSync, LibraryPanel } from '@grafana/schema';
import appEvents from 'app/core/app_events';
@@ -36,7 +37,6 @@ 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';
import { DashboardControls } from './DashboardControls';
@@ -662,8 +662,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(getCloneKey('panel-2', 1));
expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId);
expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(3670868617);
});
});
@@ -974,6 +973,10 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
body: new VizPanel({
title: 'Panel B',
key: getCloneKey('panel-2', 1),
repeatSourceKey: 'panel-2',
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'a', value: 'A' })],
}),
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }),
}),
@@ -61,7 +61,7 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { DashboardEditView } from '../settings/utils';
import { historySrv } from '../settings/version-history/HistorySrv';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { isInCloneChain } from '../utils/clone';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import { getDashboardUrl } from '../utils/getDashboardUrl';
@@ -660,9 +660,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
let panelId = 0;
if (panel && panel.state.key) {
if (isInCloneChain(panel.state.key)) {
if (isRepeatCloneOrChildOf(panel)) {
// 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);
panelId = djb2Hash(panel.getPathId());
} 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
@@ -1,10 +1,8 @@
import { AppEvents } from '@grafana/data';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { LocalValueVariable, SceneQueryRunner, SceneVariableSet, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { KioskMode } from 'app/types/dashboard';
import { getCloneKey } from '../utils/clone';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
@@ -72,7 +70,7 @@ describe('DashboardSceneUrlSync', () => {
let errorNotice = 0;
appEvents.on(AppEvents.alertError, (evt) => errorNotice++);
scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) });
scene.urlSync?.updateFromUrl({ viewPanel: 'A$panel-1' });
expect(scene.state.viewPanelScene).toBeUndefined();
// Verify no error notice was shown
@@ -87,8 +85,17 @@ describe('DashboardSceneUrlSync', () => {
key: 'griditem-1',
x: 0,
body: new VizPanel({
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: 'server',
value: 'A',
text: 'A',
}),
],
}),
title: 'Clone Panel A',
key: getCloneKey('panel-1', 1),
key: 'panel-1',
pluginId: 'table',
}),
}),
@@ -97,7 +104,7 @@ describe('DashboardSceneUrlSync', () => {
// Verify it subscribes to DashboardRepeatsProcessedEvent
scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene }));
expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1));
expect(scene.state.viewPanelScene?.getUrlKey()).toBe('A$panel-1');
});
});
@@ -11,8 +11,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/utils';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { containsCloneKey } from '../utils/clone';
import { findEditPanel, findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils';
import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId';
import { findEditPanel, getLibraryPanelBehavior } from '../utils/utils';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
@@ -74,13 +74,13 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
// Handle view panel state
if (typeof values.viewPanel === 'string') {
const panel = findVizPanelByKey(this._scene, values.viewPanel);
const panel = findVizPanelByPathId(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
// 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)) {
if (containsPathIdSeparator(values.viewPanel)) {
this._handleViewRepeatClone(values.viewPanel);
return;
}
@@ -162,7 +162,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
private _handleViewRepeatClone(viewPanel: string) {
if (!this._viewEventSub) {
this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByKey(this._scene, viewPanel);
const panel = findVizPanelByPathId(this._scene, viewPanel);
if (panel) {
this._viewEventSub?.unsubscribe();
this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) });
@@ -76,7 +76,7 @@ describe('panelMenuBehavior', () => {
expect(menu.state.items?.length).toBe(6);
// verify view panel url keeps url params and adds viewPanel=<panel-key>
expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=panel-12');
expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=a$panel-12');
// verify edit url keeps url time range
expect(menu.state.items?.[1].href).toBe('/d/dash-1?from=now-5m&to=now&editPanel=12');
// verify share
@@ -12,7 +12,7 @@ import {
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { LocalValueVariable, sceneGraph, SceneGridRow, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { LocalValueVariable, sceneGraph, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { DataQuery, OptionsWithLegend } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { createErrorNotification } from 'app/core/copy/appNotification';
@@ -35,7 +35,7 @@ import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { isInCloneChain } from '../utils/clone';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils';
@@ -74,7 +74,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
const dashboard = getDashboardSceneFor(panel);
const { isEmbedded } = dashboard.state.meta;
const exploreMenuItem = await getExploreMenuItem(panel);
const isReadOnlyRepeat = isInCloneChain(panel.state.key!);
const isReadOnlyRepeat = isRepeatCloneOrChildOf(panel);
// For embedded dashboards we only have explore action for now
if (isEmbedded) {
@@ -504,21 +504,6 @@ function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): Plu
});
}
// Handle row repeats scenario
if (panel.parent?.parent instanceof SceneGridRow) {
const row = panel.parent.parent;
if (row.state.$variables) {
row.state.$variables.state.variables.forEach((variable) => {
if (variable instanceof LocalValueVariable) {
scopedVars = {
...scopedVars,
[variable.state.name]: { value: variable.getValue(), text: variable.getValueText() },
};
}
});
}
}
return {
id,
pluginId: panel.state.pluginId,
@@ -19,7 +19,7 @@ export class ViewPanelScene extends SceneObjectBase<ViewPanelSceneState> {
}
public getUrlKey() {
return this.state.panelRef.resolve().state.key;
return this.state.panelRef.resolve().getPathId();
}
public static Component = ({ model }: SceneComponentProps<ViewPanelScene>) => {
@@ -12,6 +12,7 @@ import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { findVizPanelByPathId } from '../utils/pathId';
import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getPanelIdForVizPanel } from '../utils/utils';
@@ -21,20 +22,24 @@ import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutMana
export function setupKeyboardShortcuts(scene: DashboardScene) {
const keybindings = new KeybindingSet();
let vizPanelKey: string | null = null;
let vizPanelPathId: string | null = null;
const canEdit = scene.canEditDashboard();
const panelAttentionSubscription = appEvents.subscribe(SetPanelAttentionEvent, (event) => {
if (typeof event.payload.panelId === 'string') {
vizPanelKey = event.payload.panelId;
vizPanelPathId = event.payload.panelId;
}
});
function withFocusedPanel(scene: DashboardScene, fn: (vizPanel: VizPanel) => void) {
return () => {
const vizPanel = sceneGraph.findObject(scene, (o) => o.state.key === vizPanelKey);
if (vizPanel && vizPanel instanceof VizPanel) {
if (vizPanelPathId == null) {
return;
}
const vizPanel = findVizPanelByPathId(scene, vizPanelPathId);
if (vizPanel) {
fn(vizPanel);
return;
}
@@ -116,7 +116,10 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
const isSource = index === 0;
const clone = isSource
? panelToRepeat
: panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) });
: panelToRepeat.clone({
key: getCloneKey(panelToRepeat.state.key!, index),
repeatSourceKey: panelToRepeat.state.key,
});
clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) });
@@ -2,6 +2,7 @@ import { createRef, CSSProperties, PointerEvent as ReactPointerEvent } from 'rea
import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { getLayoutOrchestratorFor } from '../../utils/utils';
import { AutoGridItem } from './AutoGridItem';
@@ -92,6 +93,10 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
}
public isDraggable(): boolean {
if (isRepeatCloneOrChildOf(this)) {
return false;
}
return this.state.isDraggable ?? false;
}
@@ -1,6 +1,6 @@
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { findVizPanelByKey } from '../../utils/utils';
import { DashboardEditActionEvent } from '../../edit-pane/shared';
import { DashboardScene } from '../DashboardScene';
import { AutoGridItem } from './AutoGridItem';
@@ -8,33 +8,38 @@ import { AutoGridLayout } from './AutoGridLayout';
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
describe('AutoGridLayoutManager', () => {
it('Should clone the layout', () => {
const { manager } = setup();
const clone = manager.cloneLayout('foo', true) as AutoGridLayoutManager;
it('can remove panel', () => {
const { manager, panel1 } = setup();
expect(clone).not.toBe(manager);
expect(clone.state.layout).not.toBe(manager.state.layout);
expect(clone.state.layout.state.children).not.toBe(manager.state.layout.state.children);
expect(clone.state.layout.state.children.length).toBe(manager.state.layout.state.children.length);
manager.subscribeToEvent(DashboardEditActionEvent, (event) => {
event.payload.perform();
});
const panelA = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1');
expect(panelA?.state.title).toBe('Panel A');
manager.removePanel(panel1);
const panelB = findVizPanelByKey(clone, 'foo/grid-item-2/panel-2');
expect(panelB?.state.title).toBe('Panel B');
expect(manager.state.layout.state.children.length).toBe(1);
});
});
function setup() {
const panel1 = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
});
const panel2 = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
});
const gridItems = [
new AutoGridItem({
key: 'grid-item-1',
body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
}),
body: panel1,
}),
new AutoGridItem({
key: 'grid-item-2',
@@ -50,5 +55,5 @@ function setup() {
new DashboardScene({ body: manager });
return { manager };
return { manager, panel1, panel2 };
}
@@ -7,13 +7,11 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { dashboardEditActions, NewObjectAddedToCanvasEvent } from '../../edit-pane/shared';
import { serializeAutoGridLayout } from '../../serialization/layoutSerializers/AutoGridLayoutSerializer';
import { joinCloneKeys } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import {
forceRenderChildren,
getDashboardSceneFor,
getGridItemKeyForPanelId,
getPanelIdForVizPanel,
getVizPanelKeyForPanelId,
} from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
@@ -90,8 +88,13 @@ export class AutoGridLayoutManager
}
public getOutlineChildren(): SceneObject[] {
const outlineChildren = this.state.layout.state.children.map((gridItem) => gridItem.state.body);
return outlineChildren;
const children: SceneObject[] = [];
for (const child of this.state.layout.state.children) {
children.push(child.state.body, ...(child.state.repeatedPanels || []));
}
return children;
}
public addPanel(vizPanel: VizPanel) {
@@ -245,26 +248,7 @@ export class AutoGridLayoutManager
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
layout: this.state.layout.clone({
isDraggable: isSource && this.state.layout.state.isDraggable,
children: this.state.layout.state.children.map((gridItem) => {
if (gridItem instanceof AutoGridItem) {
// Get the original panel ID from the gridItem's key
const panelId = getPanelIdForVizPanel(gridItem.state.body);
const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(panelId));
return gridItem.clone({
key: gridItemKey,
body: gridItem.state.body.clone({
key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(panelId)),
}),
});
}
throw new Error('Unexpected child type');
}),
}),
});
return this.clone({});
}
public getOptions(): OptionsPaneItemDescriptor[] {
@@ -4,7 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { useHasClonedParents } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
@@ -14,7 +14,6 @@ import { AutoGridLayoutManager } from './AutoGridLayoutManager';
export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLayout>) {
const { children, isHidden } = model.useState();
const hasClonedParents = useHasClonedParents(model);
const styles = useStyles2(getStyles, model.state);
const { layoutOrchestrator, isEditing } = useDashboardState(model);
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
@@ -24,7 +23,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
return null;
}
const showCanvasActions = !hasClonedParents && isEditing;
const showCanvasActions = !isRepeatCloneOrChildOf(model) && isEditing;
return (
<div
@@ -3,7 +3,6 @@ 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 { DashboardScene } from '../DashboardScene';
@@ -42,8 +41,8 @@ describe('PanelRepeaterGridItem', () => {
expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A');
expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2');
expect(panel1.state.key).toBe('panel-1');
expect(isInCloneChain(panel2.state.key!)).toBe(true);
expect(panel1.state.repeatSourceKey).toBe(undefined);
expect(panel2.state.repeatSourceKey).toBe(repeater.state.body.state.key);
});
it('Should wait for variable to load', async () => {
@@ -173,7 +173,10 @@ export class DashboardGridItem
const isSource = index === 0;
const clone = isSource
? panelToRepeat
: panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) });
: panelToRepeat.clone({
key: getCloneKey(panelToRepeat.state.key!, index),
repeatSourceKey: panelToRepeat.state.key,
});
clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) });
@@ -210,22 +210,6 @@ describe('DefaultGridLayoutManager', () => {
expect(gridRow.state.children.length).toBe(3);
});
it('Should clone the layout correctly', () => {
const { manager } = setup();
const clone = manager.cloneLayout('foo', true) as DefaultGridLayoutManager;
const panelA = findVizPanelByKey(clone, 'foo/grid-item-0/panel-0');
expect(panelA?.state.title).toBe('Panel A');
const panelB = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1');
expect(panelB?.state.title).toBe('Panel B');
const panelC = findVizPanelByKey(clone, 'foo/panel-2/grid-item-3/panel-3');
expect(panelC?.state.title).toBe('Panel C');
const panelD = findVizPanelByKey(clone, 'foo/panel-2/grid-item-4/panel-4');
expect(panelD?.state.title).toBe('Panel D');
});
});
});
@@ -29,7 +29,7 @@ import {
ObjectsReorderedOnCanvasEvent,
} from '../../edit-pane/shared';
import { serializeDefaultGridLayout } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer';
import { isClonedKey, joinCloneKeys, useHasClonedParents } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import {
forceRenderChildren,
@@ -433,11 +433,7 @@ export class DefaultGridLayoutManager
for (const child of this.state.grid.state.children) {
// Flatten repeated grid items
if (child instanceof DashboardGridItem) {
if (child.state.repeatedPanels) {
children.push(...child.state.repeatedPanels);
} else {
children.push(child.state.body);
}
children.push(child.state.body, ...(child.state.repeatedPanels || []));
}
}
@@ -445,75 +441,7 @@ export class DefaultGridLayoutManager
}
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,
}),
});
return this.clone({});
}
public removeRow(row: SceneGridRow, removePanels = false) {
@@ -634,7 +562,7 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = useDashboard(model);
const { isEditing } = dashboard.useState();
const hasClonedParents = useHasClonedParents(model);
const hasClonedParents = isRepeatCloneOrChildOf(model);
const styles = useStyles2(getStyles);
const showCanvasActions = isEditing && config.featureToggles.dashboardNewLayouts && !hasClonedParents;
@@ -13,7 +13,7 @@ import {
} from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
import { getCloneKey, isRepeatCloneOrChildOf } from '../../utils/clone';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
@@ -54,14 +54,13 @@ 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.key).toBe('row-1');
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!, 'grid-item-1'));
expect(gridItemRow1.state.body?.state.key).toBe('canvas-1');
expect(gridItemRow1.state.key!).toBe('grid-item-1');
const row2 = grid.state.children[2] as SceneGridRow;
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
@@ -70,51 +69,14 @@ describe('RowRepeaterBehavior', () => {
expect(row2.state.actions).toBeUndefined();
const gridItemRow2 = row2.state.children[0] as SceneGridItem;
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-1'));
expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1'));
expect(gridItemRow2.state.key!).toBe(row2.state.key! + 'grid-item-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(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 () => {
const originalRow = grid.state.children[1] as SceneGridRow;
const clone1 = grid.state.children[2] as SceneGridRow;
const clone2 = grid.state.children[3] as SceneGridRow;
expect(originalRow.state.children.length).toBe(1);
expect(clone1.state.children.length).toBe(1);
expect(clone2.state.children.length).toBe(1);
clone1.setState({
children: [
...clone1.state.children,
new SceneGridItem({
x: 0,
y: 16,
width: 24,
height: 5,
key: 'grid-item-4',
body: new SceneCanvasText({
text: 'new panel',
}),
}),
],
});
grid.forceRender();
// repeater has run so there are new clone row objects
const newClone1 = grid.state.children[2] as SceneGridRow;
const newClone2 = grid.state.children[3] as SceneGridRow;
expect(originalRow.state.children.length).toBe(2);
expect(newClone1.state.children.length).toBe(2);
expect(newClone2.state.children.length).toBe(2);
expect(isRepeatCloneOrChildOf(row1)).toBe(false);
expect(isRepeatCloneOrChildOf(row2)).toBe(true);
});
it('Should push row at the bottom down', () => {
@@ -12,16 +12,7 @@ import {
VariableValueSingle,
} from '@grafana/scenes';
import {
containsCloneKey,
getLastKeyFromClone,
isClonedKeyOf,
joinCloneKeys,
getCloneKey,
isClonedKey,
getOriginalKey,
getLocalVariableValueSet,
} from '../../utils/clone';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
@@ -49,11 +40,10 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const layout = this._getLayout();
const originalRow = this._getRow();
const originalRowNonClonedPanels = originalRow.state.children.filter((child) => !isClonedKey(child.state.key!));
const sub = layout.subscribeToState(() => {
const repeatedRows = layout.state.children.filter((child) =>
isClonedKeyOf(child.state.key!, originalRow.state.key!)
const repeatedRows = layout.state.children.filter(
(child) => child instanceof SceneGridRow && child.state.repeatSourceKey === originalRow.state.key
);
// go through cloned rows, search for panels that are not clones
@@ -62,31 +52,12 @@ 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 (rowNonClonedPanels.length === originalRowNonClonedPanels.length) {
if (row.state.children.length === originalRow.state.children.length) {
continue;
}
// 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, 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);
}
this.performRepeat(true);
}
});
@@ -163,15 +134,14 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const rowClone = isSourceRow
? rowToRepeat
: rowToRepeat.clone({
key: getCloneKey(rowToRepeat.state.key!, rowIndex),
repeatSourceKey: rowToRepeat.state.key,
y: (rowToRepeat.state.y ?? 0) + rowContentHeight * rowIndex + rowIndex,
$behaviors: [],
actions: undefined,
});
const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex);
rowClone.setState({
key: rowCloneKey,
$variables: getLocalVariableValueSet(variable, variableValues[rowIndex], variableTexts[rowIndex]),
children: [],
});
@@ -180,24 +150,13 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
for (const sourceItem of rowContent) {
const sourceItemY = sourceItem.state.y ?? 0;
const cloneItemKey = joinCloneKeys(rowCloneKey, getLastKeyFromClone(sourceItem.state.key!));
const cloneItem = rowIndex > 0 ? sourceItem.clone() : sourceItem;
const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex;
const cloneItem =
rowIndex > 0
? sourceItem.clone({
isDraggable: false,
isResizable: false,
})
: sourceItem;
cloneItem.setState({
key: cloneItemKey,
y: cloneItemY,
});
// Update grid item keys on clone rows (not needed on source row)
// Needed to not have duplicate grid items keys in the same grid
if (rowIndex > 0) {
ensureUniqueKeys(cloneItem, cloneItemKey);
cloneItem.setState({ y: cloneItemY, key: rowClone.state.key + sourceItem.state.key! });
}
children.push(cloneItem);
@@ -252,9 +211,7 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number {
function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) {
const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey);
const index = allChildren.findIndex(
(child) => child instanceof SceneGridRow && getOriginalKey(child.state.key!) === getOriginalKey(rowKey)
);
const index = allChildren.findIndex((child) => child instanceof SceneGridRow && child.state.key === rowKey);
if (index === -1) {
throw new Error('RowRepeaterBehavior: Parent row not found in layout children');
@@ -284,14 +241,6 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows:
function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) {
return layout.state.children.filter(
(child) => !(child instanceof SceneGridRow) || !isClonedKeyOf(getLastKeyFromClone(child.state.key!), rowKey)
(child) => !(child instanceof SceneGridRow) || child.state.repeatSourceKey !== rowKey
);
}
function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) {
item.forEachChild((child) => {
const key = joinCloneKeys(ancestors, child.state.key!);
child.setState({ key });
ensureUniqueKeys(child, key);
});
}
@@ -45,6 +45,8 @@ export interface RowItemState extends SceneObjectState {
conditionalRendering?: ConditionalRendering;
repeatByVariable?: string;
repeatedRows?: RowItem[];
/** Marks object as a repeated object and a key pointer to source object */
repeatSourceKey?: string;
}
export class RowItem
@@ -9,7 +9,7 @@ import { SceneComponentProps } from '@grafana/scenes';
import { clearButtonStyles, Icon, Tooltip, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { useIsClone } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState, useInterpolatedTitle } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
@@ -17,7 +17,7 @@ import { RowItem } from './RowItem';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const isClone = useIsClone(model);
const isClone = isRepeatCloneOrChildOf(model);
const { isEditing } = useDashboardState(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] =
useIsConditionallyHidden(model);
@@ -38,7 +38,7 @@ describe('RowItemRepeater', () => {
expect(screen.queryByText('Row C')).toBeInTheDocument();
});
expect(rowToRepeat.state.key).toBe('row-1-clone-0');
expect(rowToRepeat.state.key).toBe('row-1');
expect(rowToRepeat.state.repeatedRows!.length).toBe(2);
expect(rowToRepeat.state.repeatedRows![0].state.key).toBe('row-1-clone-1');
});
@@ -97,12 +97,17 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co
const rowCloneKey = getCloneKey(row.state.key!, rowIndex);
const rowClone = isSourceRow
? row
: row.clone({ repeatByVariable: undefined, repeatedRows: undefined, layout: undefined });
: row.clone({
key: rowCloneKey,
repeatSourceKey: row.state.key,
repeatByVariable: undefined,
repeatedRows: undefined,
layout: undefined,
});
const layout = isSourceRow ? row.getLayout() : row.getLayout().cloneLayout(rowCloneKey, false);
rowClone.setState({
key: rowCloneKey,
$variables: getLocalVariableValueSet(variable, variableValues[rowIndex], variableTexts[rowIndex]),
layout,
});
@@ -12,7 +12,6 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
@@ -67,16 +66,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
rows: this.state.rows.map((row) => {
const key = joinCloneKeys(ancestorKey, row.state.key!);
return row.clone({
key,
layout: row.state.layout.cloneLayout(key, isSource),
});
}),
});
return this.clone({});
}
public duplicate(): DashboardLayoutManager {
@@ -188,7 +178,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
if (layout instanceof TabsLayoutManager) {
for (const tab of layout.state.tabs) {
if (isClonedKey(tab.state.key!)) {
if (tab.state.repeatSourceKey) {
continue;
}
@@ -221,21 +211,24 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
if (child instanceof SceneGridRow) {
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,
isResizable: child.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;
// Skip repeated row clones
if (child.state.repeatSourceKey) {
return;
}
const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
config.push({
title: child.state.title,
isCollapsed: !!child.state.isCollapsed,
isDraggable: child.state.isDraggable,
isResizable: child.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 {
if (!children) {
children = [];
@@ -7,7 +7,7 @@ import { Trans } from '@grafana/i18n';
import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes';
import { Button, useStyles2 } from '@grafana/ui';
import { isInCloneChain } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useClipboardState } from '../layouts-shared/useClipboardState';
@@ -21,7 +21,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
const isClone = isInCloneChain(rows[0]?.state.key || '');
const isClone = isRepeatCloneOrChildOf(model);
return (
<DragDropContext
@@ -42,6 +42,8 @@ export interface TabItemState extends SceneObjectState {
conditionalRendering?: ConditionalRendering;
repeatByVariable?: string;
repeatedTabs?: TabItem[];
/** Marks object as a repeated object and a key pointer to source object */
repeatSourceKey?: string;
}
export class TabItem
@@ -8,7 +8,7 @@ import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Box, Icon, Tab, Tooltip, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { useIsClone } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { TabItem } from './TabItem';
@@ -29,7 +29,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const styles = useStyles2(getStyles);
const pointerDistance = usePointerDistance();
const [isConditionallyHidden] = useIsConditionallyHidden(model);
const isClone = useIsClone(model);
const isClone = isRepeatCloneOrChildOf(model);
const isDraggable = !isClone && isEditing;
@@ -38,7 +38,7 @@ describe('TabItemRepeater', () => {
expect(screen.queryByText('Tab C')).toBeInTheDocument();
});
expect(tabToRepeat.state.key).toBe('tab-1-clone-0');
expect(tabToRepeat.state.key).toBe('tab-1');
expect(tabToRepeat.state.repeatedTabs!.length).toBe(2);
expect(tabToRepeat.state.repeatedTabs![0].state.key).toBe('tab-1-clone-1');
});
@@ -152,12 +152,17 @@ export function createTabRepeats({
const tabCloneKey = getCloneKey(tab.state.key!, tabIndex);
const tabClone = isSourceTab
? tab
: tab.clone({ repeatByVariable: undefined, repeatedTabs: undefined, layout: undefined });
: tab.clone({
key: tabCloneKey,
repeatSourceKey: tab.state.key,
repeatByVariable: undefined,
repeatedTabs: undefined,
layout: undefined,
});
const layout = isSourceTab ? tab.getLayout() : tab.getLayout().cloneLayout(tabCloneKey, false);
tabClone.setState({
key: tabCloneKey,
$variables: getLocalVariableValueSet(variable, variableValues[tabIndex], variableTexts[tabIndex]),
layout,
});
@@ -12,15 +12,6 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import {
containsCloneKey,
getCloneKey,
getLastKeyFromClone,
getOriginalKey,
isClonedKey,
isClonedKeyOf,
joinCloneKeys,
} from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
@@ -138,16 +129,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
tabs: this.state.tabs.map((tab) => {
const key = joinCloneKeys(ancestorKey, tab.state.key!);
return tab.clone({
key,
layout: tab.state.layout.cloneLayout(key, isSource),
});
}),
});
return this.clone();
}
public getOutlineChildren() {
@@ -257,19 +239,19 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
let destinationTab = allTabs[toIndex];
let selectionIndex = toIndex;
if (containsCloneKey(getLastKeyFromClone(destinationTab.state.key!))) {
if (isClonedKeyOf(destinationTab.state.key!, objectToMove.state.key!)) {
if (destinationTab.state.repeatSourceKey) {
if (destinationTab.state.repeatSourceKey === objectToMove.state.repeatSourceKey) {
// moving tab between its clones
return;
}
const originalTabKey = getCloneKey(getOriginalKey(destinationTab.state.key!), 0);
const originalTabIndex = allTabs.findIndex((tab) => tab.state.key === originalTabKey);
if (originalTabIndex !== -1) {
destinationTab = allTabs[originalTabIndex];
const sourceTabIndx = allTabs.findIndex((tab) => tab.state.key === destinationTab.state.repeatSourceKey);
if (sourceTabIndx !== -1) {
destinationTab = allTabs[sourceTabIndx];
const isMovingLeft = toIndex < fromIndex;
selectionIndex = originalTabIndex + (isMovingLeft ? 0 : destinationTab.state.repeatedTabs?.length || 0);
selectionIndex = sourceTabIndx + (isMovingLeft ? 0 : destinationTab.state.repeatedTabs?.length || 0);
}
}
@@ -319,7 +301,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
if (layout instanceof RowsLayoutManager) {
for (const row of layout.state.rows) {
if (isClonedKey(row.state.key!)) {
if (row.state.repeatSourceKey) {
continue;
}
@@ -8,7 +8,7 @@ import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectStat
import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { isInCloneChain } from '../../utils/clone';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
import { useClipboardState } from '../layouts-shared/useClipboardState';
@@ -27,7 +27,7 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
const { hasCopiedTab } = useClipboardState();
const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(currentTab);
const isClone = isInCloneChain(tabs[0]?.state.key || '');
const isClone = isRepeatCloneOrChildOf(model);
return (
<div className={styles.tabLayoutContainer}>
@@ -2,7 +2,6 @@ import { Spec as DashboardV2Spec, RowsLayoutRowKind } from '@grafana/schema/dist
import { RowItem } from '../../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
import { isClonedKey } from '../../utils/clone';
import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
import { getConditionalRendering } from './utils';
@@ -11,7 +10,7 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard
return {
kind: 'RowsLayout',
spec: {
rows: layoutManager.state.rows.filter((row) => !isClonedKey(row.state.key!)).map(serializeRow),
rows: layoutManager.state.rows.filter((row) => !row.state.repeatSourceKey).map(serializeRow),
},
};
}
@@ -2,7 +2,6 @@ import { Spec as DashboardV2Spec, TabsLayoutTabKind } from '@grafana/schema/dist
import { TabItem } from '../../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager';
import { isClonedKey } from '../../utils/clone';
import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
import { getConditionalRendering } from './utils';
@@ -11,7 +10,7 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard
return {
kind: 'TabsLayout',
spec: {
tabs: layoutManager.state.tabs.filter((tab) => !isClonedKey(tab.state.key!)).map(serializeTab),
tabs: layoutManager.state.tabs.filter((tab) => !tab.state.repeatSourceKey).map(serializeTab),
},
};
}
@@ -36,7 +36,6 @@ import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import {
@@ -74,9 +73,10 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
if (child instanceof SceneGridRow) {
// Skip repeat clones or when generating a snapshot
if (isClonedKey(child.state.key!) && !isSnapshot) {
if (child.state.repeatSourceKey && !isSnapshot) {
continue;
}
gridRowToSaveModel(child, panels, isSnapshot);
}
}
@@ -6,7 +6,7 @@ import { dateTime } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { LocalValueVariable, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
@@ -46,7 +46,7 @@ describe('ShareLinkTab', () => {
buildAndRenderScenario({});
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12'
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12'
);
});
});
@@ -57,7 +57,7 @@ describe('ShareLinkTab', () => {
await act(() => tab.onToggleLockedTime());
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=panel-12'
'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=A$panel-12'
);
});
});
@@ -67,7 +67,7 @@ describe('ShareLinkTab', () => {
await act(() => tab.onThemeChange('light'));
expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue(
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&theme=light'
'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12&theme=light'
);
});
@@ -88,7 +88,7 @@ describe('ShareLinkTab', () => {
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute(
'href',
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
);
});
});
@@ -102,6 +102,15 @@ function buildAndRenderScenario(options: ScenarioOptions) {
title: 'Panel A',
pluginId: 'table',
key: 'panel-12',
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: 'server',
value: 'A',
text: 'A',
}),
],
}),
});
const tab = new ShareLinkTab({ panelRef: panel.getRef() });
const scene = new DashboardScene({
@@ -76,7 +76,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
let imageQueryParams = urlParamsUpdate;
if (panel) {
delete imageQueryParams.viewPanel;
imageQueryParams.panelId = panel.state.key;
imageQueryParams.panelId = panel.getPathId();
// force solo route to use scenes
imageQueryParams['__feature.dashboardSceneSolo'] = true;
}
@@ -84,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
const imageUrl = getDashboardUrl({
uid: dashboard.state.uid,
currentQueryParams: window.location.search,
updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key },
updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.getPathId() },
absolute: true,
soloRoute: true,
render: true,
@@ -43,7 +43,7 @@ function SharePanelEmbedTabRenderer({ model }: SceneComponentProps<SharePanelEmb
return (
<ShareEmbed
panelId={p.state.key!}
panelId={p.getPathId()}
timeFrom={timeFrom}
range={timeRangeState.state.value}
dashboard={{ uid: dashUid ?? '', time: timeRangeState.state.value }}
@@ -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-1'));
const { result } = renderHook(() => useSoloPanel(dashboard, 'A$panel-1'));
const panel = findVizPanelByKey(dashboard, 'panel-1');
expect(result.current[0]).not.toBe(panel);
@@ -4,10 +4,9 @@ import { VizPanel, UrlSyncManager } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardRepeatsProcessedEvent } from '../scene/types/DashboardRepeatsProcessedEvent';
import { containsCloneKey } from '../utils/clone';
import { findVizPanelByKey } from '../utils/utils';
import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId';
export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] {
export function useSoloPanel(dashboard: DashboardScene, pathId: string): [VizPanel | undefined, string | undefined] {
const [panel, setPanel] = useState<VizPanel>();
const [error, setError] = useState<string | undefined>();
@@ -19,7 +18,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
let panel: VizPanel | null = null;
try {
panel = findVizPanelByKey(dashboard, panelId);
panel = findVizPanelByPathId(dashboard, pathId);
} catch (e) {
// do nothing, just the panel is not found or not a VizPanel
}
@@ -27,8 +26,8 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
if (panel) {
activateParents(panel);
setPanel(panel);
} else if (containsCloneKey(panelId)) {
findRepeatClone(dashboard, panelId).then((panel) => {
} else if (containsPathIdSeparator(pathId)) {
findRepeatClone(dashboard, pathId).then((panel) => {
if (panel) {
setPanel(panel);
} else {
@@ -40,7 +39,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
}
return cleanUp;
}, [dashboard, panelId]);
}, [dashboard, pathId]);
return [panel, error];
}
@@ -54,10 +53,10 @@ function activateParents(panel: VizPanel) {
}
}
function findRepeatClone(dashboard: DashboardScene, panelId: string): Promise<VizPanel | undefined> {
function findRepeatClone(dashboard: DashboardScene, pathId: string): Promise<VizPanel | undefined> {
return new Promise((resolve) => {
dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByKey(dashboard, panelId);
const panel = findVizPanelByPathId(dashboard, pathId);
if (panel) {
resolve(panel);
} else {
@@ -1,134 +1,10 @@
import {
getCloneKey,
getOriginalKey,
isInCloneChain,
isClonedKey,
joinCloneKeys,
containsCloneKey,
getLastKeyFromClone,
isClonedKeyOf,
} from './clone';
import { getCloneKey } 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');
expect(getOriginalKey('panel-2-clone-3')).toBe('panel-2');
expect(getOriginalKey('panel-2')).toBe('panel-2');
});
});
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);
});
it('should properly handle indexes containing 0', () => {
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-0')).toBe(false);
expect(isClonedKey('row-clone-0/panel-clone-0')).toBe(false);
expect(isClonedKey('panel-clone-0')).toBe(false);
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-101')).toBe(true);
expect(isClonedKey('row-clone-0/panel-clone-101')).toBe(true);
expect(isClonedKey('panel-clone-1010')).toBe(true);
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-10')).toBe(true);
expect(isClonedKey('row-clone-0/panel-clone-100')).toBe(true);
expect(isClonedKey('panel-clone-1000')).toBe(true);
});
});
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);
expect(getCloneKey('panel-1', 1)).toBe('panel-1-clone-1');
expect(getCloneKey('panel-22', 1)).toBe('panel-22-clone-1');
});
});
});
@@ -7,13 +7,7 @@ import {
VariableValueSingle,
} from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
const CLONE_KEY = '-clone-';
const CLONE_SEPARATOR = '/';
const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9][0-9]*$`);
const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
/**
* Create or alter the last key for a key
@@ -21,91 +15,21 @@ const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
* @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);
return `${key}${CLONE_KEY}${index}`;
}
/**
* Get the original key from a clone key
* @param key
*/
export function getOriginalKey(key: string): string {
return getLastKeyFromClone(key).replace(ORIGINAL_REGEX, '');
}
export function isRepeatCloneOrChildOf(scene: SceneObject): boolean {
let obj: SceneObject | undefined = scene;
/**
* Checks if the last key is a clone key
* @param key
*/
export function isClonedKey(key: string): boolean {
return CLONED_KEY_REGEX.test(getLastKeyFromClone(key));
}
do {
if ('repeatSourceKey' in obj.state && obj.state.repeatSourceKey) {
return true;
}
/**
* 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);
}
obj = obj.parent;
} while (obj);
/**
* 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);
}
/**
* Useful hook for checking of a scene is a clone
* @param scene
*/
export function useIsClone(scene: SceneObject): boolean {
const { key } = scene.useState();
return isClonedKey(key!);
}
/**
* Useful hook for checking if a scene is in a clone chain
* @param scene
*/
export function useHasClonedParents(scene: SceneObject): boolean {
if (isClonedKey(scene.state.key!)) {
return true;
}
if (!scene.parent || scene.parent instanceof DashboardScene) {
return false;
}
return useHasClonedParents(scene.parent);
return false;
}
export function getLocalVariableValueSet(
@@ -4,7 +4,6 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { VizPanelLinks } from '../scene/PanelLinks';
import { isClonedKey } from './clone';
import { getDashboardSceneFor, getLayoutManagerFor, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from './utils';
function getTimePicker(scene: DashboardScene) {
@@ -37,12 +36,11 @@ export function getNextPanelId(scene: SceneObject): number {
let max = 0;
sceneGraph
.findAllObjects(scene.getRoot(), (obj) => obj instanceof VizPanel || obj instanceof SceneGridRow)
.findAllObjects(
scene.getRoot(),
(obj) => (obj instanceof VizPanel || obj instanceof SceneGridRow) && !obj.state.repeatSourceKey
)
.forEach((panel) => {
if (isClonedKey(panel.state.key!)) {
return;
}
const panelId = getPanelIdForVizPanel(panel);
if (panelId > max) {
max = panelId;
@@ -0,0 +1,98 @@
import {
LocalValueVariable,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
SceneVariableSet,
VizPanel,
} from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { findVizPanelByPathId } from './pathId';
describe('findVizPanelByPathId', () => {
it('should find correct panel', () => {
const { scene, panel1, repeatedPanel } = buildTestScene();
expect(findVizPanelByPathId(scene, 'panel-1')).toBe(panel1);
expect(findVizPanelByPathId(scene, 'US$pod1$panel-2')).toBe(repeatedPanel);
});
it('should find correct pane with legacy number only', () => {
const { scene, panel1 } = buildTestScene();
expect(findVizPanelByPathId(scene, '1')).toBe(panel1);
// This should not find anything
expect(findVizPanelByPathId(scene, '1$panel-1')).toBe(null);
});
it('should include local and parent local variable value', () => {
const { repeatedPanel } = buildTestScene();
expect(repeatedPanel.getPathId()).toBe('US$pod1$panel-2');
});
});
function buildTestScene() {
const panel1 = new VizPanel({
title: 'Panel 1',
pluginId: 'table',
key: 'panel-1',
});
const repeatedPanel = new VizPanel({
title: 'Panel 2',
pluginId: 'table',
key: 'panel-2',
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'pod', value: 'pod1', text: 'pod1' })],
}),
});
const grid = new SceneGridLayout({
children: [
new SceneGridItem({
key: 'grid-item-1',
x: 0,
y: 0,
width: 24,
height: 10,
body: panel1,
}),
new SceneGridRow({
key: 'row-1',
x: 0,
y: 10,
width: 24,
height: 1,
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'datacenter', value: 'US', text: 'US' })],
}),
children: [
new SceneGridItem({
key: 'grid-item-2',
x: 0,
y: 11,
width: 24,
height: 5,
body: repeatedPanel,
}),
],
}),
],
});
const scene = new DashboardScene({
title: 'My dashboard',
uid: 'dash-1',
tags: ['database', 'panel'],
meta: {
canEdit: true,
},
body: new DefaultGridLayoutManager({ grid }),
});
return { scene, panel1, repeatedPanel };
}
@@ -0,0 +1,32 @@
import { SceneObject, VizPanel, sceneGraph, PATH_ID_SEPARATOR } from '@grafana/scenes';
import { getVizPanelKeyForPanelId } from './utils';
export function findVizPanelByPathId(scene: SceneObject, pathId: string): VizPanel | null {
// Check if pathId is just an old legacy panel id
if (/^\d+$/.test(pathId)) {
pathId = getVizPanelKeyForPanelId(parseInt(pathId, 10));
}
const panel = sceneGraph.findObject(scene, (obj) => {
if (!(obj instanceof VizPanel)) {
return false;
}
return pathId === obj.getPathId();
});
if (panel) {
if (panel instanceof VizPanel) {
return panel;
} else {
throw new Error(`Found panel with key ${pathId} but it was not a VizPanel`);
}
}
return null;
}
export function containsPathIdSeparator(key: string): boolean {
return key.includes(PATH_ID_SEPARATOR);
}
@@ -8,7 +8,7 @@ import { getQueryRunnerFor } from './utils';
export function getViewPanelUrl(vizPanel: VizPanel) {
return locationUtil.getUrlForPartial(locationService.getLocation(), {
viewPanel: vizPanel.state.key,
viewPanel: vizPanel.getPathId(),
editPanel: undefined,
});
}
@@ -29,8 +29,6 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { containsCloneKey, getLastKeyFromClone, getOriginalKey, isInCloneChain } from './clone';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@@ -44,7 +42,7 @@ export function getVizPanelKeyForPanelId(panelId: number) {
}
export function getPanelIdForVizPanel(panel: SceneObject): number {
return parseInt(getOriginalKey(panel.state.key!).replace('panel-', ''), 10);
return parseInt(panel.state.key!.replace('panel-', ''), 10);
}
/**
@@ -98,102 +96,16 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
return null;
}
export function findOriginalVizPanelByKey(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) {
return null;
}
let panel: VizPanel | null = findOriginalVizPanelInternal(scene, key);
if (panel) {
return panel;
}
// Also try to find by panel id
const id = parseInt(key, 10);
if (isNaN(id)) {
return null;
}
const panelId = getVizPanelKeyForPanelId(id);
panel = findVizPanelInternal(scene, panelId);
if (panel) {
return panel;
}
panel = findOriginalVizPanelInternal(scene, panelId);
return panel;
}
function findOriginalVizPanelInternal(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) {
return null;
}
const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
// Compare the original keys
if (objKey === key || (!isInCloneChain(objKey) && getOriginalKey(objKey) === getOriginalKey(key))) {
return true;
}
if (!(obj instanceof VizPanel)) {
return false;
}
return false;
});
if (panel) {
if (panel instanceof VizPanel) {
return panel;
} else {
throw new Error(`Found panel with key ${key} but it was not a VizPanel`);
}
}
return null;
}
export function findEditPanel(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) {
return null;
}
// First we try to find the non-cloned panel
// This means it is either in not in a repeat chain or every item in the chain is not a clone
let panel: SceneObject | null = findOriginalVizPanelByKey(scene, key);
let panel: SceneObject | null = findVizPanelByKey(scene, key);
if (!panel || !panel.state.key) {
return null;
}
// Get the actual panel key, without any of the ancestors
const panelKey = getLastKeyFromClone(panel.state.key);
// If the panel contains clone in the key, this means it's a repeated panel, and we need to find the original panel
if (containsCloneKey(panelKey)) {
// Get the original key of the panel that we are looking for
const originalPanelKey = getOriginalKey(panelKey);
// Start the search from the parent to avoid unnecessary checks
// The parent usually is the grid item where the referenced panel is also located
panel = sceneGraph.findObject(panel.parent ?? scene, (sceneObject) => {
if (!sceneObject.state.key || isInCloneChain(sceneObject.state.key)) {
return false;
}
const currentLastKey = getLastKeyFromClone(sceneObject.state.key);
if (containsCloneKey(currentLastKey)) {
return false;
}
return getOriginalKey(currentLastKey) === originalPanelKey;
});
}
if (!(panel instanceof VizPanel)) {
return null;
}
@@ -24,7 +24,7 @@ import { config } from '@grafana/runtime';
import { SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes';
import {
activateSceneObjectAndParentTree,
findOriginalVizPanelByKey,
findVizPanelByKey,
getVizPanelKeyForPanelId,
} from 'app/features/dashboard-scene/utils/utils';
@@ -63,7 +63,7 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
return of({ data: [] });
}
let sourcePanel = this.findSourcePanel(scene, panelId);
let sourcePanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId));
if (!sourcePanel) {
return of({ data: [], error: { message: 'Could not find source panel' } });
@@ -295,11 +295,6 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
};
}
private findSourcePanel(scene: SceneObject, panelId: number) {
// We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones
return findOriginalVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId));
}
private emitFirstLoadedDataIfMixedDS(
requestId: string
): (source: Observable<DataQueryResponse>) => Observable<DataQueryResponse> {
+11 -11
View File
@@ -3586,11 +3586,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.29.7":
version: 6.29.7
resolution: "@grafana/scenes-react@npm:6.29.7"
"@grafana/scenes-react@npm:^6.30.0":
version: 6.30.0
resolution: "@grafana/scenes-react@npm:6.30.0"
dependencies:
"@grafana/scenes": "npm:6.29.7"
"@grafana/scenes": "npm:6.30.0"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3602,13 +3602,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/d3db42b8face33a871cc2b983b00890b4a7b9cf685b06dd02df730036f9c13f1cdbc0dcb578453a0ce29381bdceb26d2a07ae4be77a8f753677dde1560eb491f
checksum: 10/107b930aaf88945cbc51601443190357d9733bd3e9063fa7d2fd7496ad772e25f7cbddd34fd6a2adeb5ae85d39d03cecf8d2ad3d74a351e61fedabb17dfce82a
languageName: node
linkType: hard
"@grafana/scenes@npm:6.29.7":
version: 6.29.7
resolution: "@grafana/scenes@npm:6.29.7"
"@grafana/scenes@npm:6.30.0, @grafana/scenes@npm:^6.30.0":
version: 6.30.0
resolution: "@grafana/scenes@npm:6.30.0"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3628,7 +3628,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/a2bbda33c78cd568d6333e033e1b587917ee4ca00d8f69e3707197d6712beba1d1b9244f05259bc4cce7416b9ec67d24eddaa1e6073695e2b4a7af1a37253174
checksum: 10/b1036a1d8c531b3e197c3de276c4fe4a7092fcd27e1fd350ff6530cabb8eb5436a6184840ef6a4e6747510326e58c9a73d54b0c36f8ee7d20f8fd6eaccea8d75
languageName: node
linkType: hard
@@ -18313,8 +18313,8 @@ __metadata:
"@grafana/plugin-ui": "npm:0.10.9"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.29.7"
"@grafana/scenes-react": "npm:6.29.7"
"@grafana/scenes": "npm:^6.30.0"
"@grafana/scenes-react": "npm:^6.30.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"