mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 18:42:27 +08:00
RowsLayout: Rethinking row repeats (#104312)
* Something is working * Update * Update * working * clear repeated rows * Update * Update * Outline via function * Update * Update * Update * Progress * Update * Udpate sum * Update * Update public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Update * more tests * Update * Update * Removed old behavior * Update * Update * Update * fix outline for default grid * Update --------- Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
This commit is contained in:
@ -14,6 +14,7 @@ import {
|
||||
ConditionalRenderingChangedEvent,
|
||||
DashboardEditActionEvent,
|
||||
DashboardEditActionEventPayload,
|
||||
DashboardStateChangedEvent,
|
||||
NewObjectAddedToCanvasEvent,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
@ -114,6 +115,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
}
|
||||
|
||||
action.undo();
|
||||
action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true);
|
||||
|
||||
/**
|
||||
* Some edit actions also require clearing selection or selecting new objects
|
||||
@ -138,6 +140,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
*/
|
||||
private performAction(action: DashboardEditActionEventPayload) {
|
||||
action.perform();
|
||||
action.source.publishEvent(new DashboardStateChangedEvent({ source: action.source }), true);
|
||||
|
||||
if (action.addedObject) {
|
||||
this.newObjectAddedToCanvas(action.addedObject);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ReactNode, useMemo, useRef } from 'react';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Button, Input, TextArea } from '@grafana/ui';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@ -22,10 +23,14 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
typeName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'),
|
||||
icon: 'apps',
|
||||
instanceName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'),
|
||||
isContainer: true,
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
const { $variables, body } = this.dashboard.state;
|
||||
return [$variables!, ...body.getOutlineChildren()];
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const dashboard = this.dashboard;
|
||||
|
||||
|
@ -1,15 +1,12 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { sortBy } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Box, Icon, Text, useElementSelection, useStyles2 } from '@grafana/ui';
|
||||
import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { isInCloneChain } from '../utils/clone';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
@ -47,11 +44,11 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
|
||||
|
||||
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
|
||||
|
||||
const children = sortBy(collectEditableElementChildren(sceneObject, [], 0), 'depth');
|
||||
const children = editableElement.getOutlineChildren?.() ?? [];
|
||||
const elementInfo = editableElement.getEditableElementInfo();
|
||||
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
|
||||
const elementCollapsed = editableElement.getCollapsedState?.();
|
||||
const outlineRename = useOutlineRename(editableElement);
|
||||
const isContainer = editableElement.getOutlineChildren ? true : false;
|
||||
|
||||
const onNodeClicked = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@ -67,20 +64,8 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
|
||||
const onToggleCollapse = (evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
setIsCollapsed(!isCollapsed);
|
||||
|
||||
// Sync expanded state with canvas element
|
||||
if (editableElement.getCollapsedState) {
|
||||
editableElement.setCollapsedState?.(!isCollapsed);
|
||||
}
|
||||
};
|
||||
|
||||
// Sync canvas element expanded state with outline element
|
||||
useEffect(() => {
|
||||
if (elementCollapsed === !isCollapsed) {
|
||||
setIsCollapsed(elementCollapsed);
|
||||
}
|
||||
}, [isCollapsed, elementCollapsed]);
|
||||
|
||||
return (
|
||||
// todo: add proper keyboard navigation
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
@ -93,7 +78,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
|
||||
>
|
||||
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
|
||||
<div className={styles.indentation}></div>
|
||||
{elementInfo.isContainer && (
|
||||
{isContainer && (
|
||||
<button
|
||||
className={styles.angleButton}
|
||||
onClick={onToggleCollapse}
|
||||
@ -120,24 +105,25 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span>{instanceName}</span>
|
||||
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
|
||||
{elementInfo.isContainer && isCollapsed && <span>({children.length})</span>}
|
||||
<Stack direction="row" gap={0.5} alignItems="center" grow={1}>
|
||||
<span>{instanceName}</span>
|
||||
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
|
||||
</Stack>
|
||||
{isCloned && (
|
||||
<span>
|
||||
<Trans i18nKey="dashboard.outline.repeated-item">Repeat</Trans>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{elementInfo.isContainer && !isCollapsed && (
|
||||
{isContainer && !isCollapsed && (
|
||||
<ul className={styles.nodeChildren} role="group">
|
||||
{children.length > 0 ? (
|
||||
children.map((child) => (
|
||||
<DashboardOutlineNode
|
||||
key={child.sceneObject.state.key}
|
||||
sceneObject={child.sceneObject}
|
||||
editPane={editPane}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
<DashboardOutlineNode key={child.state.key} sceneObject={child} editPane={editPane} depth={depth + 1} />
|
||||
))
|
||||
) : (
|
||||
<Text color="secondary" element="li">
|
||||
@ -249,41 +235,3 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
interface EditableElementConfig {
|
||||
sceneObject: SceneObject;
|
||||
editableElement: EditableDashboardElement;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function collectEditableElementChildren(
|
||||
sceneObject: SceneObject,
|
||||
children: EditableElementConfig[],
|
||||
depth: number
|
||||
): EditableElementConfig[] {
|
||||
sceneObject.forEachChild((child) => {
|
||||
const editableElement = getEditableElementFor(child);
|
||||
|
||||
if (editableElement) {
|
||||
children.push({ sceneObject: child, editableElement, depth });
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof DashboardGridItem) {
|
||||
// DashboardGridItem is a special case as it can contain repeated panels
|
||||
// In this case, we want to show the repeated panels as separate items, otherwise show the body panel
|
||||
if (child.state.repeatedPanels?.length) {
|
||||
for (const repeatedPanel of child.state.repeatedPanels) {
|
||||
const editableElement = getEditableElementFor(repeatedPanel)!;
|
||||
children.push({ sceneObject: repeatedPanel, editableElement, depth });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
collectEditableElementChildren(child, children, depth + 1);
|
||||
});
|
||||
|
||||
return children;
|
||||
}
|
||||
|
@ -93,6 +93,13 @@ export class DashboardEditActionEvent extends BusEventWithPayload<DashboardEditA
|
||||
static type = 'dashboard-edit-action';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted after DashboardEditActionEvent has been processed (or undone)
|
||||
*/
|
||||
export class DashboardStateChangedEvent extends BusEventWithPayload<{ source: SceneObject }> {
|
||||
static type = 'dashboard-state-changed';
|
||||
}
|
||||
|
||||
export interface AddElementActionHelperProps {
|
||||
addedObject: SceneObject;
|
||||
source: SceneObject;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { GRID_CELL_VMARGIN } from 'app/core/constants';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@ -88,6 +88,11 @@ export class AutoGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
const outlineChildren = this.state.layout.state.children.map((gridItem) => gridItem.state.body);
|
||||
return outlineChildren;
|
||||
}
|
||||
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
const panelId = dashboardSceneGraph.getNextPanelId(this);
|
||||
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
SceneGridItemLike,
|
||||
useSceneObjectState,
|
||||
SceneGridLayoutDragStartEvent,
|
||||
SceneObject,
|
||||
} from '@grafana/scenes';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
@ -399,6 +400,23 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
const children: SceneObject[] = [];
|
||||
|
||||
for (const child of this.state.grid.state.children) {
|
||||
// Flatten repeated grid items
|
||||
if (child instanceof DashboardGridItem) {
|
||||
if (child.state.repeatedPanels) {
|
||||
children.push(...child.state.repeatedPanels);
|
||||
} else {
|
||||
children.push(child.state.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
|
||||
return this.clone({
|
||||
grid: this.state.grid.clone({
|
||||
|
@ -28,10 +28,13 @@ export class SceneGridRowEditableElement implements EditableDashboardElement, Bu
|
||||
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
|
||||
instanceName: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'),
|
||||
icon: 'list-ul',
|
||||
isContainer: true,
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren() {
|
||||
return this._row.state.children;
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const row = this._row;
|
||||
|
||||
|
@ -32,7 +32,6 @@ import { LayoutParent } from '../types/LayoutParent';
|
||||
|
||||
import { useEditOptions } from './RowItemEditor';
|
||||
import { RowItemRenderer } from './RowItemRenderer';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowItems } from './RowItems';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
@ -44,6 +43,8 @@ export interface RowItemState extends SceneObjectState {
|
||||
fillScreen?: boolean;
|
||||
isDropTarget?: boolean;
|
||||
conditionalRendering?: ConditionalRendering;
|
||||
repeatByVariable?: string;
|
||||
repeatedRows?: RowItem[];
|
||||
}
|
||||
|
||||
export class RowItem
|
||||
@ -86,10 +87,13 @@ export class RowItem
|
||||
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
|
||||
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
|
||||
icon: 'list-ul',
|
||||
isContainer: true,
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
return this.state.layout.getOutlineChildren();
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
return this.state.layout;
|
||||
}
|
||||
@ -176,10 +180,6 @@ export class RowItem
|
||||
this.setIsDropTarget(false);
|
||||
}
|
||||
|
||||
public getRepeatVariable(): string | undefined {
|
||||
return this._getRepeatBehavior()?.state.variableName;
|
||||
}
|
||||
|
||||
public onChangeTitle(title: string) {
|
||||
this.setState({ title });
|
||||
}
|
||||
@ -197,19 +197,10 @@ export class RowItem
|
||||
}
|
||||
|
||||
public onChangeRepeat(repeat: string | undefined) {
|
||||
let repeatBehavior = this._getRepeatBehavior();
|
||||
|
||||
if (repeat) {
|
||||
// Remove repeat behavior if it exists to trigger repeat when adding new one
|
||||
if (repeatBehavior) {
|
||||
repeatBehavior.removeBehavior();
|
||||
}
|
||||
|
||||
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
|
||||
this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
|
||||
repeatBehavior.activate();
|
||||
this.setState({ repeatByVariable: repeat });
|
||||
} else {
|
||||
repeatBehavior?.removeBehavior();
|
||||
this.setState({ repeatedRows: undefined, $variables: undefined, repeatByVariable: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,10 +212,6 @@ export class RowItem
|
||||
return sceneGraph.getAncestor(this, RowsLayoutManager);
|
||||
}
|
||||
|
||||
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
|
||||
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
|
||||
}
|
||||
|
||||
public scrollIntoView() {
|
||||
scrollCanvasElementIntoView(this, this.containerRef);
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ function RowRepeatSelect({ row }: { row: RowItem }) {
|
||||
<>
|
||||
<RepeatRowSelect2
|
||||
sceneContext={dashboard}
|
||||
repeat={row.getRepeatVariable()}
|
||||
repeat={row.state.repeatByVariable}
|
||||
onChange={(repeat) => row.onChangeRepeat(repeat)}
|
||||
/>
|
||||
{isAnyPanelUsingDashboardDS ? (
|
||||
|
@ -0,0 +1,165 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { VariableRefresh } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import { SceneTimeRange, SceneVariableSet, TestVariable, VariableValueOption, PanelBuilders } from '@grafana/scenes';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
|
||||
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
|
||||
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
|
||||
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
|
||||
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
}));
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: () => undefined,
|
||||
});
|
||||
|
||||
describe('RowItemRepeater', () => {
|
||||
describe('Given scene with variable with 3 values', () => {
|
||||
it('Should repeat row', async () => {
|
||||
const { rowToRepeat } = renderScene({ variableQueryTime: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Row A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Row B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Row C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(rowToRepeat.state.key).toBe('row-1-clone-0');
|
||||
expect(rowToRepeat.state.repeatedRows!.length).toBe(2);
|
||||
expect(rowToRepeat.state.repeatedRows![0].state.key).toBe('row-1-clone-1');
|
||||
});
|
||||
|
||||
it('Should update repeats when variable value changes', async () => {
|
||||
const { repeatByVariable, rowToRepeat } = renderScene({ variableQueryTime: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Row C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
repeatByVariable.changeValueTo(['C', 'D']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Row A')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Row D')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(rowToRepeat.state.repeatedRows!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should skip update repeats when variable values the same', async () => {
|
||||
const { repeatByVariable, rowToRepeat } = renderScene({ variableQueryTime: 0 });
|
||||
let stateUpdates = 0;
|
||||
|
||||
rowToRepeat.subscribeToState((s) => stateUpdates++);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Row C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
repeatByVariable.changeValueTo(['A1', 'B1', 'C1']);
|
||||
});
|
||||
|
||||
expect(stateUpdates).toBe(1);
|
||||
});
|
||||
|
||||
it('Should handle removing repeats', async () => {
|
||||
const { rowToRepeat } = renderScene({ variableQueryTime: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Row C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rowToRepeat.onChangeRepeat(undefined);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Row C')).not.toBeInTheDocument();
|
||||
expect(rowToRepeat.state.$variables).toBe(undefined);
|
||||
expect(rowToRepeat.state.repeatedRows).toBe(undefined);
|
||||
expect(rowToRepeat.state.repeatByVariable).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SceneOptions {
|
||||
variableQueryTime: number;
|
||||
variableRefresh?: VariableRefresh;
|
||||
}
|
||||
|
||||
function buildTextPanel(key: string, content: string) {
|
||||
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
|
||||
panel.setState({ key });
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderScene(
|
||||
options: SceneOptions,
|
||||
variableOptions?: VariableValueOption[],
|
||||
variableStateOverrides?: { isMulti: boolean }
|
||||
) {
|
||||
const rows = [
|
||||
new RowItem({
|
||||
key: 'row-1',
|
||||
title: 'Row $server',
|
||||
repeatByVariable: 'server',
|
||||
layout: new AutoGridLayoutManager({
|
||||
layout: new AutoGridLayout({
|
||||
children: [
|
||||
new AutoGridItem({
|
||||
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = new RowsLayoutManager({ rows });
|
||||
const repeatByVariable = new TestVariable({
|
||||
name: 'server',
|
||||
query: 'A.*',
|
||||
value: ALL_VARIABLE_VALUE,
|
||||
text: ALL_VARIABLE_TEXT,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
delayMs: options.variableQueryTime,
|
||||
refresh: options.variableRefresh,
|
||||
optionsToReturn: variableOptions ?? [
|
||||
{ label: 'A', value: 'A1' },
|
||||
{ label: 'B', value: 'B1' },
|
||||
{ label: 'C', value: 'C1' },
|
||||
],
|
||||
...variableStateOverrides,
|
||||
});
|
||||
|
||||
const scene = new DashboardScene({
|
||||
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [repeatByVariable],
|
||||
}),
|
||||
body: layout,
|
||||
});
|
||||
|
||||
const rowToRepeat = rows[0];
|
||||
|
||||
render(<scene.Component model={scene} />);
|
||||
|
||||
return { scene, layout, rows, rowToRepeat, repeatByVariable };
|
||||
}
|
@ -0,0 +1,160 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
MultiValueVariable,
|
||||
SceneVariableSet,
|
||||
LocalValueVariable,
|
||||
sceneGraph,
|
||||
VariableValueSingle,
|
||||
} from '@grafana/scenes';
|
||||
import { Spinner } from '@grafana/ui';
|
||||
|
||||
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
|
||||
import { getCloneKey } from '../../utils/clone';
|
||||
import { dashboardLog, getMultiVariableValues } from '../../utils/utils';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
export interface Props {
|
||||
row: RowItem;
|
||||
manager: RowsLayoutManager;
|
||||
variable: MultiValueVariable;
|
||||
}
|
||||
|
||||
export function RowItemRepeater({
|
||||
row,
|
||||
variable,
|
||||
}: {
|
||||
row: RowItem;
|
||||
manager: RowsLayoutManager;
|
||||
variable: MultiValueVariable;
|
||||
}) {
|
||||
const { repeatedRows } = row.useState();
|
||||
|
||||
// Subscribe to variable state changes and perform repeats when the variable changes
|
||||
useEffect(() => {
|
||||
performRowRepeats(variable, row, false);
|
||||
|
||||
const variableChangeSub = variable.subscribeToState((state) => performRowRepeats(variable, row, false));
|
||||
const editEventSub = row.subscribeToEvent(DashboardStateChangedEvent, (e) =>
|
||||
performRowRepeats(variable, row, true)
|
||||
);
|
||||
|
||||
return () => {
|
||||
editEventSub.unsubscribe();
|
||||
variableChangeSub.unsubscribe();
|
||||
};
|
||||
}, [variable, row]);
|
||||
|
||||
if (
|
||||
repeatedRows === undefined ||
|
||||
sceneGraph.hasVariableDependencyInLoadingState(variable) ||
|
||||
variable.state.loading
|
||||
) {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Variable is loading, showing spinner');
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
{repeatedRows?.map((rowClone) => <rowClone.Component model={rowClone} key={rowClone.state.key!} />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function performRowRepeats(variable: MultiValueVariable, row: RowItem, contentChanged: boolean) {
|
||||
if (sceneGraph.hasVariableDependencyInLoadingState(variable)) {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Skipped dependency in loading state');
|
||||
return;
|
||||
}
|
||||
|
||||
if (variable.state.loading) {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Skipped, variable is loading');
|
||||
return;
|
||||
}
|
||||
|
||||
const { values, texts } = getMultiVariableValues(variable);
|
||||
const prevValues = getPrevRepeatValues(row, variable.state.name);
|
||||
|
||||
if (!contentChanged && isEqual(prevValues, values)) {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Skipped, values the same');
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentChanged) {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Performing repeats, contentChanged');
|
||||
} else {
|
||||
dashboardLog.logger('RowItemRepeater', false, 'Performing repeats, variable values changed', values);
|
||||
}
|
||||
|
||||
const variableValues = values.length ? values : [''];
|
||||
const variableTexts = texts.length ? texts : variable.hasAllValue() ? ['All'] : ['None'];
|
||||
const clonedRows: RowItem[] = [];
|
||||
|
||||
// Loop through variable values and create repeats
|
||||
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
|
||||
const isSourceRow = rowIndex === 0;
|
||||
const rowCloneKey = getCloneKey(row.state.key!, rowIndex);
|
||||
const rowClone = isSourceRow
|
||||
? row
|
||||
: row.clone({ repeatByVariable: undefined, repeatedRows: undefined, layout: undefined });
|
||||
|
||||
const layout = isSourceRow ? row.getLayout() : row.getLayout().cloneLayout(rowCloneKey, false);
|
||||
|
||||
rowClone.setState({
|
||||
key: rowCloneKey,
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new LocalValueVariable({
|
||||
name: variable.state.name,
|
||||
value: variableValues[rowIndex],
|
||||
text: String(variableTexts[rowIndex]),
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
layout,
|
||||
});
|
||||
|
||||
if (!isSourceRow) {
|
||||
clonedRows.push(rowClone);
|
||||
}
|
||||
}
|
||||
|
||||
row.setState({ repeatedRows: clonedRows });
|
||||
row.publishEvent(new DashboardRepeatsProcessedEvent({ source: row }), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get previous variable values given the current repeated state
|
||||
*/
|
||||
function getPrevRepeatValues(mainRow: RowItem, varName: string): VariableValueSingle[] {
|
||||
const values: VariableValueSingle[] = [];
|
||||
|
||||
if (!mainRow.state.repeatedRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectVariableValue(row: RowItem) {
|
||||
const variable = sceneGraph.lookupVariable(varName, row);
|
||||
if (variable) {
|
||||
const value = variable.getValue();
|
||||
if (value != null && !Array.isArray(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectVariableValue(mainRow);
|
||||
|
||||
for (const row of mainRow.state.repeatedRows) {
|
||||
collectVariableValue(row);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
@ -1,272 +0,0 @@
|
||||
import { VariableRefresh } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import {
|
||||
SceneGridRow,
|
||||
SceneTimeRange,
|
||||
SceneVariableSet,
|
||||
TestVariable,
|
||||
VariableValueOption,
|
||||
PanelBuilders,
|
||||
} from '@grafana/scenes';
|
||||
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
|
||||
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
|
||||
|
||||
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
|
||||
import { activateFullSceneTree } from '../../utils/test-utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
}));
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: () => undefined,
|
||||
});
|
||||
|
||||
describe('RowItemRepeaterBehavior', () => {
|
||||
describe('Given scene with variable with 5 values', () => {
|
||||
let scene: DashboardScene, layout: RowsLayoutManager, repeatBehavior: RowItemRepeaterBehavior;
|
||||
let layoutStateUpdates: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
|
||||
|
||||
layoutStateUpdates = [];
|
||||
layout.subscribeToState((state) => layoutStateUpdates.push(state));
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
});
|
||||
|
||||
it('Should repeat row', () => {
|
||||
// Verify that first row still has repeat behavior
|
||||
const row1 = layout.state.rows[0];
|
||||
expect(row1.state.key).toBe(getCloneKey('row-1', 0));
|
||||
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
|
||||
|
||||
const row1Children = getRowChildren(row1);
|
||||
expect(row1Children[0].state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-0'));
|
||||
expect(row1Children[0].state.body?.state.key).toBe(joinCloneKeys(row1Children[0].state.key!, 'panel-0'));
|
||||
|
||||
const row2 = layout.state.rows[1];
|
||||
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
|
||||
expect(row2.state.$behaviors).toEqual([]);
|
||||
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
|
||||
|
||||
const row2Children = getRowChildren(row2);
|
||||
expect(row2Children[0].state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-0'));
|
||||
expect(row2Children[0].state.body?.state.key).toBe(joinCloneKeys(row2Children[0].state.key!, 'panel-0'));
|
||||
});
|
||||
|
||||
it('Repeated rows should be read only', () => {
|
||||
const row1 = layout.state.rows[0];
|
||||
expect(isInCloneChain(row1.state.key!)).toBe(false);
|
||||
|
||||
const row2 = layout.state.rows[1];
|
||||
expect(isInCloneChain(row2.state.key!)).toBe(true);
|
||||
});
|
||||
|
||||
it('Should push row at the bottom down', () => {
|
||||
// Should push row at the bottom down
|
||||
const rowAtTheBottom = layout.state.rows[5];
|
||||
expect(rowAtTheBottom.state.title).toBe('Row at the bottom');
|
||||
});
|
||||
|
||||
it('Should handle second repeat cycle and update remove old repeats', async () => {
|
||||
// trigger another repeat cycle by changing the variable
|
||||
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
|
||||
variable.changeValueTo(['B1', 'C1']);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// should now only have 2 repeated rows (and the panel above + the row at the bottom)
|
||||
expect(layout.state.rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('Should ignore repeat process if variable values are the same', async () => {
|
||||
// trigger another repeat cycle by changing the variable
|
||||
repeatBehavior.performRepeat();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
expect(layoutStateUpdates.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Given scene with variable with 15 values', () => {
|
||||
let scene: DashboardScene, layout: RowsLayoutManager;
|
||||
let layoutStateUpdates: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
({ scene, layout } = buildScene({ variableQueryTime: 0 }, [
|
||||
{ label: 'A', value: 'A1' },
|
||||
{ label: 'B', value: 'B1' },
|
||||
{ label: 'C', value: 'C1' },
|
||||
{ label: 'D', value: 'D1' },
|
||||
{ label: 'E', value: 'E1' },
|
||||
{ label: 'F', value: 'F1' },
|
||||
{ label: 'G', value: 'G1' },
|
||||
{ label: 'H', value: 'H1' },
|
||||
{ label: 'I', value: 'I1' },
|
||||
{ label: 'J', value: 'J1' },
|
||||
{ label: 'K', value: 'K1' },
|
||||
{ label: 'L', value: 'L1' },
|
||||
{ label: 'M', value: 'M1' },
|
||||
{ label: 'N', value: 'N1' },
|
||||
{ label: 'O', value: 'O1' },
|
||||
]));
|
||||
|
||||
layoutStateUpdates = [];
|
||||
layout.subscribeToState((state) => layoutStateUpdates.push(state));
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
});
|
||||
|
||||
it('Should handle second repeat cycle and update remove old repeats', async () => {
|
||||
// should have 15 repeated rows (and the panel above)
|
||||
expect(layout.state.rows.length).toBe(16);
|
||||
|
||||
// trigger another repeat cycle by changing the variable
|
||||
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
|
||||
variable.changeValueTo(['B1', 'C1']);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// should now only have 2 repeated rows (and the panel above)
|
||||
expect(layout.state.rows.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Given a scene with empty variable', () => {
|
||||
it('Should preserve repeat row', async () => {
|
||||
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
|
||||
activateFullSceneTree(scene);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// Should have 2 rows, one without repeat and one with the dummy row
|
||||
expect(layout.state.rows.length).toBe(2);
|
||||
expect(layout.state.rows[0].state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SceneOptions {
|
||||
variableQueryTime: number;
|
||||
variableRefresh?: VariableRefresh;
|
||||
}
|
||||
|
||||
function buildTextPanel(key: string, content: string) {
|
||||
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
|
||||
panel.setState({ key });
|
||||
return panel;
|
||||
}
|
||||
|
||||
function buildScene(
|
||||
options: SceneOptions,
|
||||
variableOptions?: VariableValueOption[],
|
||||
variableStateOverrides?: { isMulti: boolean }
|
||||
) {
|
||||
const repeatBehavior = new RowItemRepeaterBehavior({ variableName: 'server' });
|
||||
|
||||
const rows = [
|
||||
new RowItem({
|
||||
key: 'row-1',
|
||||
$behaviors: [repeatBehavior],
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'grid-item-1',
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 24,
|
||||
height: 5,
|
||||
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
new RowItem({
|
||||
key: 'row-2',
|
||||
title: 'Row at the bottom',
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'grid-item-2',
|
||||
x: 0,
|
||||
y: 17,
|
||||
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
|
||||
}),
|
||||
new DashboardGridItem({
|
||||
key: 'grid-item-3',
|
||||
x: 0,
|
||||
y: 25,
|
||||
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = new RowsLayoutManager({ rows });
|
||||
|
||||
const scene = new DashboardScene({
|
||||
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new TestVariable({
|
||||
name: 'server',
|
||||
query: 'A.*',
|
||||
value: ALL_VARIABLE_VALUE,
|
||||
text: ALL_VARIABLE_TEXT,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
delayMs: options.variableQueryTime,
|
||||
refresh: options.variableRefresh,
|
||||
optionsToReturn: variableOptions ?? [
|
||||
{ label: 'A', value: 'A1' },
|
||||
{ label: 'B', value: 'B1' },
|
||||
{ label: 'C', value: 'C1' },
|
||||
{ label: 'D', value: 'D1' },
|
||||
{ label: 'E', value: 'E1' },
|
||||
],
|
||||
...variableStateOverrides,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body: layout,
|
||||
});
|
||||
|
||||
const rowToRepeat = repeatBehavior.parent as SceneGridRow;
|
||||
|
||||
return { scene, layout, rows, repeatBehavior, rowToRepeat };
|
||||
}
|
||||
|
||||
function getRowLayout(row: RowItem): DefaultGridLayoutManager {
|
||||
const layout = row.getLayout();
|
||||
|
||||
if (!(layout instanceof DefaultGridLayoutManager)) {
|
||||
throw new Error('Invalid layout');
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
function getRowChildren(row: RowItem): DashboardGridItem[] {
|
||||
const layout = getRowLayout(row);
|
||||
|
||||
const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
|
||||
|
||||
if (filteredChildren.length !== layout.state.grid.state.children.length) {
|
||||
throw new Error('Invalid layout');
|
||||
}
|
||||
|
||||
return filteredChildren;
|
||||
}
|
@ -1,161 +0,0 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import {
|
||||
LocalValueVariable,
|
||||
MultiValueVariable,
|
||||
sceneGraph,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneVariableSet,
|
||||
VariableDependencyConfig,
|
||||
VariableValueSingle,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
interface RowItemRepeaterBehaviorState extends SceneObjectState {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
export class RowItemRepeaterBehavior extends SceneObjectBase<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));
|
||||
}
|
@ -3,6 +3,7 @@ import {
|
||||
sceneGraph,
|
||||
SceneGridItemLike,
|
||||
SceneGridRow,
|
||||
SceneObject,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
@ -24,7 +25,6 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
|
||||
|
||||
interface RowsLayoutManagerState extends SceneObjectState {
|
||||
@ -33,7 +33,6 @@ interface RowsLayoutManagerState extends SceneObjectState {
|
||||
|
||||
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
|
||||
public static Component = RowLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
|
||||
public static readonly descriptor: LayoutRegistryItem = {
|
||||
@ -120,26 +119,26 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
this.addNewRow(row);
|
||||
}
|
||||
|
||||
public activateRepeaters() {
|
||||
this.state.rows.forEach((row) => {
|
||||
if (!row.isActive) {
|
||||
row.activate();
|
||||
}
|
||||
|
||||
const behavior = (row.state.$behaviors ?? []).find((b) => b instanceof RowItemRepeaterBehavior);
|
||||
|
||||
if (!behavior?.isActive) {
|
||||
behavior?.activate();
|
||||
}
|
||||
|
||||
row.getLayout().activateRepeaters?.();
|
||||
});
|
||||
}
|
||||
|
||||
public shouldUngroup(): boolean {
|
||||
return this.state.rows.length === 1;
|
||||
}
|
||||
|
||||
public getOutlineChildren() {
|
||||
const outlineChildren: SceneObject[] = [];
|
||||
|
||||
for (const row of this.state.rows) {
|
||||
outlineChildren.push(row);
|
||||
|
||||
if (row.state.repeatedRows) {
|
||||
for (const clone of row.state.repeatedRows!) {
|
||||
outlineChildren.push(clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outlineChildren;
|
||||
}
|
||||
|
||||
public removeRow(row: RowItem) {
|
||||
// When removing last row replace ourselves with the inner row layout
|
||||
if (this.shouldUngroup()) {
|
||||
@ -198,12 +197,14 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
conditionalRendering?.clearParent();
|
||||
|
||||
const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
|
||||
const $behaviors = !behavior
|
||||
? undefined
|
||||
: [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
|
||||
|
||||
rows.push(
|
||||
new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, $behaviors })
|
||||
new RowItem({
|
||||
layout: tab.state.layout.clone(),
|
||||
title: tab.state.title,
|
||||
conditionalRendering,
|
||||
repeatByVariable: behavior?.state.variableName,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (layout instanceof DefaultGridLayoutManager) {
|
||||
@ -253,12 +254,12 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
new RowItem({
|
||||
title: rowConfig.title,
|
||||
collapse: !!rowConfig.isCollapsed,
|
||||
repeatByVariable: rowConfig.repeat,
|
||||
layout: DefaultGridLayoutManager.fromGridItems(
|
||||
rowConfig.children,
|
||||
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
|
||||
rowConfig.isResizable ?? layout.state.grid.state.isResizable
|
||||
),
|
||||
$behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
@ -4,12 +4,14 @@ import { DragDropContext, Droppable } from '@hello-pangea/dnd';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes';
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
import { useClipboardState } from '../layouts-shared/useClipboardState';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeater } from './RowItemRepeater';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
|
||||
@ -37,7 +39,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
|
||||
{(dropProvided) => (
|
||||
<div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{rows.map((row) => (
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
<RowWrapper row={row} manager={model} key={row.state.key!} />
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
{isEditing && (
|
||||
@ -71,6 +73,20 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
|
||||
);
|
||||
}
|
||||
|
||||
function RowWrapper({ row, manager }: { row: RowItem; manager: RowsLayoutManager }) {
|
||||
const { repeatByVariable } = useSceneObjectState(row, { shouldActivateOrKeepAlive: true });
|
||||
|
||||
if (repeatByVariable) {
|
||||
const variable = sceneGraph.lookupVariable(repeatByVariable, manager);
|
||||
|
||||
if (variable instanceof MultiValueVariable) {
|
||||
return <RowItemRepeater row={row} key={row.state.key!} manager={manager} variable={variable} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <row.Component model={row} key={row.state.key!} />;
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
|
@ -84,10 +84,13 @@ export class TabItem
|
||||
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
|
||||
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
|
||||
icon: 'layers',
|
||||
isContainer: true,
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
return this.state.layout.getOutlineChildren();
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
return this.state.layout;
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsL
|
||||
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { RowItem } from '../layout-rows/RowItem';
|
||||
import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { getTabFromClipboard } from '../layouts-shared/paste';
|
||||
import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
|
||||
@ -134,6 +133,10 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
});
|
||||
}
|
||||
|
||||
public getOutlineChildren() {
|
||||
return this.state.tabs;
|
||||
}
|
||||
|
||||
public addNewTab(tab?: TabItem) {
|
||||
const newTab = tab ?? new TabItem({});
|
||||
const existingNames = new Set(this.state.tabs.map((tab) => tab.state.title).filter((title) => title !== undefined));
|
||||
@ -283,10 +286,9 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
const conditionalRendering = row.state.conditionalRendering;
|
||||
conditionalRendering?.clearParent();
|
||||
|
||||
const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
|
||||
const $behaviors = !behavior
|
||||
? undefined
|
||||
: [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })];
|
||||
const $behaviors = row.state.repeatByVariable
|
||||
? [new TabItemRepeaterBehavior({ variableName: row.state.repeatByVariable })]
|
||||
: undefined;
|
||||
|
||||
tabs.push(
|
||||
new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors })
|
||||
|
@ -81,6 +81,11 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
|
||||
* Paste a panel from the clipboard
|
||||
*/
|
||||
pastePanel?(): void;
|
||||
|
||||
/**
|
||||
* Get children for outline
|
||||
*/
|
||||
getOutlineChildren(): SceneObject[];
|
||||
}
|
||||
|
||||
export interface LayoutManagerSerializer {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { IconName } from '@grafana/data';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
/**
|
||||
@ -69,16 +70,17 @@ export interface EditableDashboardElement {
|
||||
* Used to change name from outline
|
||||
*/
|
||||
onChangeName?(name: string): { errorMessage?: string } | void;
|
||||
|
||||
/**
|
||||
* Container objects can have children
|
||||
*/
|
||||
getOutlineChildren?(): SceneObject[];
|
||||
}
|
||||
|
||||
export interface EditableDashboardElementInfo {
|
||||
instanceName: string;
|
||||
typeName: string;
|
||||
icon: IconName;
|
||||
/**
|
||||
* Mark it as a container of other editable elements
|
||||
*/
|
||||
isContainer?: boolean;
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { AutoGridLayout } from '../../scene/layout-auto-grid/AutoGridLayout';
|
||||
import { AutoGridLayoutManager } from '../../scene/layout-auto-grid/AutoGridLayoutManager';
|
||||
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowItem } from '../../scene/layout-rows/RowItem';
|
||||
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
|
||||
|
||||
import { deserializeRowsLayout, serializeRowsLayout } from './RowsLayoutSerializer';
|
||||
@ -169,12 +168,7 @@ describe('deserialization', () => {
|
||||
expect(deserialized.state.rows).toHaveLength(1);
|
||||
|
||||
const row = deserialized.state.rows[0];
|
||||
expect(row.state.$behaviors).toBeDefined();
|
||||
const behaviors = row.state.$behaviors ?? [];
|
||||
expect(behaviors).toHaveLength(1);
|
||||
const repeaterBehavior = behaviors[0] as RowItemRepeaterBehavior;
|
||||
expect(repeaterBehavior).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
expect(repeaterBehavior.state.variableName).toBe('foo');
|
||||
expect(row.state.repeatByVariable).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@ -270,7 +264,7 @@ describe('serialization', () => {
|
||||
isResizable: true,
|
||||
}),
|
||||
}),
|
||||
$behaviors: [new RowItemRepeaterBehavior({ variableName: 'foo' })],
|
||||
repeatByVariable: 'foo',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
|
||||
import { RowItem } from '../../scene/layout-rows/RowItem';
|
||||
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
|
||||
@ -30,6 +29,12 @@ export function serializeRow(row: RowItem): RowsLayoutRowKind {
|
||||
layout: layout,
|
||||
fillScreen: row.state.fillScreen,
|
||||
hideHeader: row.state.hideHeader,
|
||||
...(row.state.repeatByVariable && {
|
||||
repeat: {
|
||||
mode: 'variable',
|
||||
value: row.state.repeatByVariable,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -39,16 +44,6 @@ export function serializeRow(row: RowItem): RowsLayoutRowKind {
|
||||
rowKind.spec.conditionalRendering = conditionalRenderingRootGroup;
|
||||
}
|
||||
|
||||
if (row.state.$behaviors) {
|
||||
for (const behavior of row.state.$behaviors) {
|
||||
if (behavior instanceof RowItemRepeaterBehavior) {
|
||||
if (rowKind.spec.repeat) {
|
||||
throw new Error('Multiple repeaters are not supported');
|
||||
}
|
||||
rowKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' };
|
||||
}
|
||||
}
|
||||
}
|
||||
return rowKind;
|
||||
}
|
||||
|
||||
@ -72,16 +67,13 @@ export function deserializeRow(
|
||||
panelIdGenerator?: () => number
|
||||
): RowItem {
|
||||
const layout = row.spec.layout;
|
||||
const $behaviors = !row.spec.repeat
|
||||
? undefined
|
||||
: [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })];
|
||||
|
||||
return new RowItem({
|
||||
title: row.spec.title,
|
||||
collapse: row.spec.collapse,
|
||||
hideHeader: row.spec.hideHeader,
|
||||
fillScreen: row.spec.fillScreen,
|
||||
$behaviors,
|
||||
repeatByVariable: row.spec.repeat?.value,
|
||||
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
|
||||
conditionalRendering: getConditionalRendering(row),
|
||||
});
|
||||
|
@ -33,7 +33,6 @@ import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
|
||||
import { NEW_LINK } from '../settings/links/utils';
|
||||
import { getQueryRunnerFor } from '../utils/utils';
|
||||
@ -845,10 +844,7 @@ describe('transformSaveModelToScene', () => {
|
||||
|
||||
const row2 = layout.state.rows[1];
|
||||
|
||||
expect(row2.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
|
||||
|
||||
const repeatBehavior = row2.state.$behaviors?.[0] as RowItemRepeaterBehavior;
|
||||
expect(repeatBehavior.state.variableName).toBe('server');
|
||||
expect(row2.state.repeatByVariable).toBe('server');
|
||||
|
||||
const lastRow = layout.state.rows[layout.state.rows.length - 1];
|
||||
expect(lastRow.state.title).toBe('Row at the bottom - not repeated - saved collapsed ');
|
||||
|
@ -45,7 +45,6 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay
|
||||
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
|
||||
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
|
||||
import { RowItem } from '../scene/layout-rows/RowItem';
|
||||
import { RowItemRepeaterBehavior } from '../scene/layout-rows/RowItemRepeaterBehavior';
|
||||
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
|
||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
|
||||
@ -239,7 +238,7 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[]
|
||||
children: (row.panels?.map((p) => buildGridItemForPanel(p)) ?? []).concat(panels),
|
||||
}),
|
||||
}),
|
||||
$behaviors: row.repeat ? [new RowItemRepeaterBehavior({ variableName: row.repeat })] : undefined,
|
||||
repeatByVariable: row.repeat,
|
||||
});
|
||||
return rowItem;
|
||||
}
|
||||
|
@ -28,10 +28,13 @@ export class VariableSetEditableElement implements EditableDashboardElement {
|
||||
typeName: t('dashboard.edit-pane.elements.variable-set', 'Variables'),
|
||||
icon: 'x',
|
||||
instanceName: t('dashboard.edit-pane.elements.variable-set', 'Variables'),
|
||||
isContainer: true,
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren() {
|
||||
return this.set.state.variables;
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const set = this.set;
|
||||
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
VizPanel,
|
||||
VizPanelMenu,
|
||||
} from '@grafana/scenes';
|
||||
import { createLogger } from '@grafana/ui';
|
||||
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
|
||||
|
||||
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
|
||||
@ -468,3 +469,5 @@ export function useInterpolatedTitle<T extends SceneObjectState & { title?: stri
|
||||
export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrchestrator | undefined {
|
||||
return getDashboardSceneFor(scene).state.layoutOrchestrator;
|
||||
}
|
||||
|
||||
export const dashboardLog = createLogger('Dashboard');
|
||||
|
@ -4593,6 +4593,7 @@
|
||||
"title-matched_other": "Matched {{count}}/{{totalCount}} options"
|
||||
},
|
||||
"outline": {
|
||||
"repeated-item": "Repeat",
|
||||
"tree-item": {
|
||||
"empty": "(empty)",
|
||||
"no-title": "<no title>"
|
||||
|
@ -10,7 +10,12 @@ import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { HistoryWrapper, LocationServiceProvider, setLocationService } from '@grafana/runtime';
|
||||
import {
|
||||
HistoryWrapper,
|
||||
LocationServiceProvider,
|
||||
setChromeHeaderHeightHook,
|
||||
setLocationService,
|
||||
} from '@grafana/runtime';
|
||||
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
||||
import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
@ -106,6 +111,8 @@ const customRender = (
|
||||
const store = renderOptions.preloadedState ? configureStore(renderOptions?.preloadedState) : undefined;
|
||||
const AllTheProviders = renderOptions.wrapper || getWrapper({ store, renderWithRouter, ...renderOptions });
|
||||
|
||||
setChromeHeaderHeightHook(() => 40);
|
||||
|
||||
return {
|
||||
...render(ui, { wrapper: AllTheProviders, ...renderOptions }),
|
||||
/** Instance of `userEvent.setup()` ready for use to interact with rendered component */
|
||||
|
Reference in New Issue
Block a user