Scenes: Row controls (#83607)

* wip row controls

* wip row repeats

* wip

* wip

* row repeat functional

* refactor

* refactor to reuse RepeatRowSelect2

* refactor + tests

* remove comment

* refactor
pull/84163/head
Victor Marin 1 year ago committed by GitHub
parent 87d6bebb9e
commit 9c22a6144e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx
  2. 41
      public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
  3. 19
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  4. 33
      public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx
  5. 8
      public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts
  6. 208
      public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx
  7. 50
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx
  8. 51
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx
  9. 61
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx
  10. 40
      public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx
  11. 2
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  12. 2
      public/app/features/dashboard-scene/utils/utils.ts
  13. 11
      public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx
  14. 11
      public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx

@ -15,12 +15,15 @@ interface Props {
}
export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMode }) => {
const { panel } = vizManager.useState();
const { panel, repeat } = vizManager.useState();
const { data } = sceneGraph.getData(panel).useState();
const { options, fieldConfig } = panel.useState();
// eslint-disable-next-line react-hooks/exhaustive-deps
const panelFrameOptions = useMemo(() => getPanelFrameCategory2(vizManager), [vizManager, panel]);
const panelFrameOptions = useMemo(
() => getPanelFrameCategory2(vizManager, panel, repeat),
[vizManager, panel, repeat]
);
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();

@ -338,6 +338,47 @@ describe('DashboardScene', () => {
expect(gridRow.state.children.length).toBe(0);
});
it('Should remove a row and move its children to the grid layout', () => {
const body = scene.state.body as SceneGridLayout;
const row = body.state.children[2] as SceneGridRow;
scene.removeRow(row);
const vizPanel = (body.state.children[2] as SceneGridItem).state.body as VizPanel;
expect(body.state.children.length).toBe(6);
expect(vizPanel.state.key).toBe('panel-4');
});
it('Should remove a row and its children', () => {
const body = scene.state.body as SceneGridLayout;
const row = body.state.children[2] as SceneGridRow;
scene.removeRow(row, true);
expect(body.state.children.length).toBe(4);
});
it('Should remove an empty row from the layout', () => {
const row = new SceneGridRow({
key: 'panel-1',
});
const scene = buildTestScene({
body: new SceneGridLayout({
children: [row],
}),
});
const body = scene.state.body as SceneGridLayout;
expect(body.state.children.length).toBe(1);
scene.removeRow(row);
expect(body.state.children.length).toBe(0);
});
it('Should fail to copy a panel if it does not have a grid item parent', () => {
const vizPanel = new VizPanel({
title: 'Panel Title',

@ -412,6 +412,25 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
});
}
public removeRow(row: SceneGridRow, removePanels = false) {
if (!(this.state.body instanceof SceneGridLayout)) {
throw new Error('Trying to add a panel in a layout that is not SceneGridLayout');
}
const sceneGridLayout = this.state.body;
const children = sceneGridLayout.state.children.filter((child) => child.state.key !== row.state.key);
if (!removePanels) {
const rowChildren = row.state.children.map((child) => child.clone());
const indexOfRow = sceneGridLayout.state.children.findIndex((child) => child.state.key === row.state.key);
children.splice(indexOfRow, 0, ...rowChildren);
}
sceneGridLayout.setState({ children });
}
public addPanel(vizPanel: VizPanel): void {
if (!(this.state.body instanceof SceneGridLayout)) {
throw new Error('Trying to add a panel in a layout that is not SceneGridLayout');

@ -50,6 +50,19 @@ describe('RowRepeaterBehavior', () => {
expect(rowAtTheBottom.state.y).toBe(40);
});
it('Should push row at the bottom down and also offset its children', () => {
const rowAtTheBottom = grid.state.children[6] as SceneGridRow;
const rowChildOne = rowAtTheBottom.state.children[0] as SceneGridItem;
const rowChildTwo = rowAtTheBottom.state.children[1] as SceneGridItem;
expect(rowAtTheBottom.state.title).toBe('Row at the bottom');
// Panel at the top is 10, each row is (1+5)*5 = 30, so the grid item below it should be 40
expect(rowAtTheBottom.state.y).toBe(40);
expect(rowChildOne.state.y).toBe(41);
expect(rowChildTwo.state.y).toBe(49);
});
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;
@ -111,6 +124,26 @@ function buildScene(options: SceneOptions) {
width: 24,
height: 5,
title: 'Row at the bottom',
children: [
new SceneGridItem({
key: 'griditem-2',
x: 0,
y: 17,
body: new SceneCanvasText({
key: 'canvas-2',
text: 'Panel inside row, server = $server',
}),
}),
new SceneGridItem({
key: 'griditem-3',
x: 0,
y: 25,
body: new SceneCanvasText({
key: 'canvas-3',
text: 'Panel inside row, server = $server',
}),
}),
],
}),
],
});

@ -180,8 +180,12 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows:
const diff = maxYOfRows - firstChildAfterY;
for (const child of childrenAfter) {
if (child.state.y! < maxYOfRows) {
child.setState({ y: child.state.y! + diff });
child.setState({ y: child.state.y! + diff });
if (child instanceof SceneGridRow) {
for (const rowChild of child.state.children) {
rowChild.setState({ y: rowChild.state.y! + diff });
}
}
}
}

@ -0,0 +1,208 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
SceneQueryRunner,
VizPanel,
} from '@grafana/scenes';
import { Icon, TextLink, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { ShowConfirmModalEvent } from 'app/types/events';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { RowOptionsButton } from './RowOptionsButton';
export interface RowActionsState extends SceneObjectState {}
export class RowActions extends SceneObjectBase<RowActionsState> {
private updateLayout(rowClone: SceneGridRow): void {
const row = this.getParent();
const layout = this.getDashboard().state.body;
if (!(layout instanceof SceneGridLayout)) {
throw new Error('Layout is not a SceneGridLayout');
}
// remove the repeated rows
const children = layout.state.children.filter((child) => !child.state.key?.startsWith(`${row.state.key}-clone-`));
// get the index to replace later
const index = children.indexOf(row);
if (index === -1) {
throw new Error('Parent row not found in layout children');
}
// replace the row with the clone
layout.setState({
children: [...children.slice(0, index), rowClone, ...children.slice(index + 1)],
});
}
public getParent(): SceneGridRow {
if (!(this.parent instanceof SceneGridRow)) {
throw new Error('RowActions must have a SceneGridRow parent');
}
return this.parent;
}
public getDashboard(): DashboardScene {
return getDashboardSceneFor(this);
}
public onUpdate = (title: string, repeat?: string | null): void => {
const row = this.getParent();
// return early if there is no repeat
if (!repeat) {
const clone = row.clone();
// remove the row repeater behaviour, leave the rest
clone.setState({
title,
$behaviors: row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [],
});
this.updateLayout(clone);
return;
}
const children = row.state.children.map((child) => child.clone());
const newBehaviour = new RowRepeaterBehavior({
variableName: repeat,
sources: children,
});
// get rest of behaviors except the old row repeater, if any, and push new one
const behaviors = row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [];
behaviors.push(newBehaviour);
row.setState({
title,
$behaviors: behaviors,
});
newBehaviour.activate();
};
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',
icon: 'trash-alt',
onConfirm: () => {
this.getDashboard().removeRow(this.getParent(), true);
},
onAltAction: () => {
this.getDashboard().removeRow(this.getParent());
},
})
);
};
public getWarning = () => {
const row = this.getParent();
const gridItems = row.state.children;
const isAnyPanelUsingDashboardDS = gridItems.some((gridItem) => {
if (!(gridItem instanceof SceneGridItem)) {
return false;
}
if (gridItem.state.body instanceof VizPanel && gridItem.state.body.state.$data instanceof SceneQueryRunner) {
return gridItem.state.body.state.$data?.state.datasource?.uid === SHARED_DASHBOARD_QUERY;
}
return false;
});
if (isAnyPanelUsingDashboardDS) {
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.
</p>
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
Learn more
</TextLink>
</div>
);
}
return undefined;
};
static Component = ({ model }: SceneComponentProps<RowActions>) => {
const dashboard = model.getDashboard();
const row = model.getParent();
const { title } = row.useState();
const { meta, isEditing } = dashboard.useState();
const styles = useStyles2(getStyles);
const behaviour = row.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
return (
<>
{meta.canEdit && isEditing && (
<>
<div className={styles.rowActions}>
<RowOptionsButton
title={title}
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined}
parent={dashboard}
onUpdate={model.onUpdate}
warning={model.getWarning()}
/>
<button type="button" onClick={model.onDelete} aria-label="Delete row">
<Icon name="trash-alt" />
</button>
</div>
</>
)}
</>
);
};
}
const getStyles = (theme: GrafanaTheme2) => {
return {
rowActions: css({
color: theme.colors.text.secondary,
lineHeight: '27px',
button: {
color: theme.colors.text.secondary,
paddingLeft: theme.spacing(2),
background: 'transparent',
border: 'none',
'&:hover': {
color: theme.colors.text.maxContrast,
},
},
}),
};
};

@ -0,0 +1,50 @@
import React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Icon, ModalsController } from '@grafana/ui';
import { OnRowOptionsUpdate } from './RowOptionsForm';
import { RowOptionsModal } from './RowOptionsModal';
export interface RowOptionsButtonProps {
title: string;
repeat?: string;
parent: SceneObject;
onUpdate: OnRowOptionsUpdate;
warning?: React.ReactNode;
}
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: RowOptionsButtonProps) => {
const onUpdateChange = (hideModal: () => void) => (title: string, repeat?: string | null) => {
onUpdate(title, repeat);
hideModal();
};
return (
<ModalsController>
{({ showModal, hideModal }) => {
return (
<button
type="button"
className="pointer"
aria-label="Row options"
onClick={() => {
showModal(RowOptionsModal, {
title,
repeat,
parent,
onDismiss: hideModal,
onUpdate: onUpdateChange(hideModal),
warning,
});
}}
>
<Icon name="cog" />
</button>
);
}}
</ModalsController>
);
};
RowOptionsButton.displayName = 'RowOptionsButton';

@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardScene } from '../DashboardScene';
import { RowOptionsForm } from './RowOptionsForm';
jest.mock('app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect', () => ({
RepeatRowSelect2: () => <div />,
}));
describe('DashboardRow', () => {
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
});
it('Should show warning component when has warningMessage prop', () => {
render(
<TestProvider>
<RowOptionsForm
repeat={'3'}
parent={scene}
title=""
onCancel={jest.fn()}
onUpdate={jest.fn()}
warning="a warning message"
/>
</TestProvider>
);
expect(
screen.getByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage)
).toBeInTheDocument();
});
it('Should not show warning component when does not have warningMessage prop', () => {
render(
<TestProvider>
<RowOptionsForm repeat={'3'} parent={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} />
</TestProvider>
);
expect(
screen.queryByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage)
).not.toBeInTheDocument();
});
});

@ -0,0 +1,61 @@
import React, { useCallback, useState } from 'react';
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 { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
export interface Props {
title: string;
repeat?: string;
parent: SceneObject;
onUpdate: OnRowOptionsUpdate;
onCancel: () => void;
warning?: React.ReactNode;
}
export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => {
const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat);
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]);
const { handleSubmit, register } = useForm({
defaultValues: {
title,
},
});
const submit = (formData: { title: string }) => {
onUpdate(formData.title, newRepeat);
};
return (
<form onSubmit={handleSubmit(submit)}>
<Field label="Title">
<Input {...register('title')} type="text" />
</Field>
<Field label="Repeat for">
<RepeatRowSelect2 parent={parent} repeat={newRepeat} onChange={onChangeRepeat} />
</Field>
{warning && (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
title=""
topSpacing={3}
bottomSpacing={0}
>
{warning}
</Alert>
)}
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit">Update</Button>
</Modal.ButtonRow>
</form>
);
};

@ -0,0 +1,40 @@
import { css } from '@emotion/css';
import React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Modal, useStyles2 } from '@grafana/ui';
import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm';
export interface RowOptionsModalProps {
title: string;
repeat?: string;
parent: SceneObject;
warning?: React.ReactNode;
onDismiss: () => void;
onUpdate: OnRowOptionsUpdate;
}
export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, warning }: RowOptionsModalProps) => {
const styles = useStyles2(getStyles);
return (
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}>
<RowOptionsForm
parent={parent}
repeat={repeat}
title={title}
onCancel={onDismiss}
onUpdate={onUpdate}
warning={warning}
/>
</Modal>
);
};
const getStyles = () => ({
modal: css({
label: 'RowOptionsModal',
width: '500px',
}),
});

@ -47,6 +47,7 @@ import { PanelNotices } from '../scene/PanelNotices';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { RowActions } from '../scene/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { DashboardInteractions } from '../utils/interactions';
@ -211,6 +212,7 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]):
isCollapsed: row.collapsed,
children: children,
$behaviors: behaviors,
actions: new RowActions({}),
});
}

@ -17,6 +17,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { RowActions } from '../scene/row-actions/RowActions';
import { dashboardSceneGraph } from './dashboardSceneGraph';
@ -233,6 +234,7 @@ export function getDefaultRow(dashboard: DashboardScene): SceneGridRow {
return new SceneGridRow({
key: getVizPanelKeyForPanelId(id),
title: 'Row title',
actions: new RowActions({}),
y: 0,
});
}

@ -2,6 +2,7 @@ import React from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui';
import { VizPanelManager, VizPanelManagerState } from 'app/features/dashboard-scene/panel-edit/VizPanelManager';
import { VizPanelLinks } from 'app/features/dashboard-scene/scene/PanelLinks';
@ -176,8 +177,11 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane
);
}
export function getPanelFrameCategory2(panelManager: VizPanelManager): OptionsPaneCategoryDescriptor {
const { panel } = panelManager.state;
export function getPanelFrameCategory2(
panelManager: VizPanelManager,
panel: VizPanel,
repeat?: string
): OptionsPaneCategoryDescriptor {
const descriptor = new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'Panel options',
@ -270,7 +274,8 @@ export function getPanelFrameCategory2(panelManager: VizPanelManager): OptionsPa
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
panelManager={panelManager}
parent={panel}
repeat={repeat}
onChange={(value?: string) => {
const stateUpdate: Partial<VizPanelManagerState> = { repeat: value };
if (value && !panelManager.state.repeatDirection) {

@ -1,9 +1,8 @@
import React, { useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { sceneGraph } from '@grafana/scenes';
import { SceneObject, sceneGraph } from '@grafana/scenes';
import { Select } from '@grafana/ui';
import { VizPanelManager } from 'app/features/dashboard-scene/panel-edit/VizPanelManager';
import { useSelector } from 'app/types';
import { getLastKey, getVariablesByKey } from '../../../variables/state/selectors';
@ -45,14 +44,14 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => {
};
interface Props2 {
panelManager: VizPanelManager;
parent: SceneObject;
repeat: string | undefined;
id?: string;
onChange: (name?: string) => void;
}
export const RepeatRowSelect2 = ({ panelManager, id, onChange }: Props2) => {
const { panel, repeat } = panelManager.useState();
const sceneVars = useMemo(() => sceneGraph.getVariables(panel), [panel]);
export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => {
const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]);
const variables = sceneVars.useState().variables;
const variableOptions = useMemo(() => {

Loading…
Cancel
Save