mirror of https://github.com/grafana/grafana.git
Dashboards: Implement rows repeat in rows layout (#99300)
This commit is contained in:
parent
8c0b812874
commit
61f5f215ee
|
@ -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"]
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' }] }),
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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',
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
|
@ -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}
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Åľęřŧ řūľęş",
|
||||
|
|
Loading…
Reference in New Issue