Dashboards: Finalize refactoring for dynamic dashboards (#100198)

This commit is contained in:
Bogdan Matei 2025-02-07 12:57:54 +02:00 committed by GitHub
parent 4b9fee61a8
commit a51e785bc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1215 additions and 1220 deletions

View File

@ -152,7 +152,7 @@ export class ElementSelection {
const firstObj = this.selectedObjects?.values().next().value?.resolve();
if (firstObj instanceof VizPanel) {
return new MultiSelectedVizPanelsEditableElement(sceneObjects);
return new MultiSelectedVizPanelsEditableElement(sceneObjects.filter((obj) => obj instanceof VizPanel));
}
if (isEditableDashboardElement(firstObj!)) {

View File

@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { Stack, Text, Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
@ -9,31 +10,32 @@ import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelec
export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Objects';
public readonly key: string;
private items?: BulkActionElement[];
constructor(items: BulkActionElement[]) {
this.items = items;
constructor(private _elements: BulkActionElement[]) {
this.key = uuidv4();
}
public onDelete = () => {
for (const item of this.items || []) {
item.onDelete();
}
};
renderActions(): ReactNode {
public renderActions(): ReactNode {
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.objects.multi-select.selection-number">No. of objects selected: </Trans>
{this.items?.length}
<Trans
i18nKey="dashboard.edit-pane.objects.multi-select.selection-number"
values={{ length: this._elements.length }}
>
No. of objects selected: {{ length }}
</Trans>
</Text>
<Stack direction="row">
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
</Stack>
</Stack>
);
}
public onDelete() {
this._elements.forEach((item) => item.onDelete());
}
}

View File

@ -1,9 +1,9 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { VizPanel } from '@grafana/scenes';
import { Button, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@ -11,42 +11,35 @@ import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Panels';
public readonly key: string;
private items?: VizPanel[];
constructor(items: SceneObject[]) {
this.items = [];
for (const item of items) {
if (item instanceof VizPanel) {
this.items.push(item);
}
}
constructor(private _panels: VizPanel[]) {
this.key = uuidv4();
}
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
return [];
}
public onDelete = () => {
for (const panel of this.items || []) {
const layout = dashboardSceneGraph.getLayoutManagerFor(panel);
layout.removePanel(panel);
}
};
renderActions(): ReactNode {
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.panels.multi-select.selection-number">No. of panels selected: </Trans>
{this.items?.length}
<Trans
i18nKey="dashboard.edit-pane.panels.multi-select.selection-number"
values={{ length: this._panels.length }}
>
No. of panels selected: {{ length }}
</Trans>
</Text>
<Stack direction="row">
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
</Stack>
</Stack>
);
}
public onDelete() {
this._panels.forEach((panel) => {
const layout = dashboardSceneGraph.getLayoutManagerFor(panel);
layout.removePanel?.(panel);
});
}
}

View File

@ -101,7 +101,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
public onDelete = () => {
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
layout.removePanel(this.panel);
layout.removePanel?.(this.panel);
};
public renderActions(): ReactNode {

View File

@ -502,7 +502,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
public duplicatePanel(vizPanel: VizPanel) {
getLayoutManagerFor(vizPanel).duplicatePanel(vizPanel);
getLayoutManagerFor(vizPanel).duplicatePanel?.(vizPanel);
}
public copyPanel(vizPanel: VizPanel) {
@ -536,7 +536,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
public removePanel(panel: VizPanel) {
getLayoutManagerFor(panel).removePanel(panel);
getLayoutManagerFor(panel).removePanel?.(panel);
}
public unlinkLibraryPanel(panel: VizPanel) {

View File

@ -1,14 +1,10 @@
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { useMemo } from 'react';
import { config } from '@grafana/runtime';
import {
VizPanel,
SceneObjectBase,
SceneGridLayout,
SceneVariableSet,
SceneComponentProps,
SceneGridItemStateLike,
SceneGridItemLike,
sceneGraph,
@ -17,18 +13,18 @@ import {
CustomVariable,
VizPanelState,
VariableValueSingle,
SceneVariable,
SceneVariableDependencyConfigLike,
} from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { 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 { getMultiVariableValues } from '../../utils/utils';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
import { DashboardGridItemRenderer } from './DashboardGridItemRenderer';
import { DashboardGridItemVariableDependencyHandler } from './DashboardGridItemVariableDependencyHandler';
export interface DashboardGridItemState extends SceneGridItemStateLike {
body: VizPanel;
@ -45,11 +41,14 @@ export class DashboardGridItem
extends SceneObjectBase<DashboardGridItemState>
implements SceneGridItemLike, DashboardLayoutItem
{
private _prevRepeatValues?: VariableValueSingle[];
public static Component = DashboardGridItemRenderer;
protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this);
public readonly isDashboardLayoutItem = true;
private _prevRepeatValues?: VariableValueSingle[];
public constructor(state: DashboardGridItemState) {
super(state);
@ -63,21 +62,16 @@ export class DashboardGridItem
}
}
/**
* Uses the current repeat item count to calculate the user intended desired itemHeight
*/
private _handleGridResize(newState: DashboardGridItemState, prevState: DashboardGridItemState) {
const itemCount = this.state.repeatedPanels?.length ?? 1;
const stateChange: Partial<DashboardGridItemState> = {};
// Height changed
if (newState.height === prevState.height) {
return;
}
if (this.getRepeatDirection() === 'v') {
const itemHeight = Math.ceil(newState.height! / itemCount);
stateChange.itemHeight = itemHeight;
stateChange.itemHeight = Math.ceil(newState.height! / itemCount);
} else {
const rowCount = Math.ceil(itemCount / this.getMaxPerRow());
stateChange.itemHeight = Math.ceil(newState.height! / rowCount);
@ -88,6 +82,37 @@ export class DashboardGridItem
}
}
public getClassName(): string {
return this.state.variableName ? 'panel-repeater-grid-item' : '';
}
public getOptions(): OptionsPaneCategoryDescriptor {
return getDashboardGridItemOptions(this);
}
public editingStarted() {
if (!this.state.variableName) {
return;
}
if (this.state.repeatedPanels?.length ?? 0 > 1) {
this.state.body.setState({
$variables: this.state.repeatedPanels![0].state.$variables?.clone(),
$data: this.state.repeatedPanels![0].state.$data?.clone(),
});
}
}
public editingCompleted(withChanges: boolean) {
if (withChanges) {
this._prevRepeatValues = undefined;
}
if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) {
this.setState({ width: GRID_COLUMN_COUNT });
}
}
public performRepeat() {
if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) {
return;
@ -111,9 +136,6 @@ export class DashboardGridItem
const { values, texts } = getMultiVariableValues(variable);
if (isEqual(this._prevRepeatValues, values)) {
// In some cases, like for variables that depend on time range, the panel query runners are waiting for the top level variable to complete
// So even when there was no change in the variable value (like in this case) we need to notify the query runners that the variable has completed it's update
// this.notifyRepeatedPanelsWaitingForVariables(variable);
return;
}
@ -121,7 +143,7 @@ export class DashboardGridItem
const repeatedPanels: VizPanel[] = [];
// 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'],
@ -163,7 +185,6 @@ export class DashboardGridItem
this.setState(stateChange);
// In case we updated our height the grid layout needs to be update
if (prevHeight !== this.state.height) {
const layout = sceneGraph.getLayout(this);
if (layout instanceof SceneGridLayout) {
@ -173,7 +194,6 @@ export class DashboardGridItem
this._prevRepeatValues = values;
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
@ -191,167 +211,23 @@ export class DashboardGridItem
this.setState(stateUpdate);
}
/**
* Returns options for panel edit
*/
public getOptions(): OptionsPaneCategoryDescriptor {
return getDashboardGridItemOptions(this);
}
/**
* Logic to prep panel for panel edit
*/
public editingStarted() {
if (!this.state.variableName) {
return;
}
if (this.state.repeatedPanels?.length ?? 0 > 1) {
this.state.body.setState({
$variables: this.state.repeatedPanels![0].state.$variables?.clone(),
$data: this.state.repeatedPanels![0].state.$data?.clone(),
});
}
}
/**
* Going back to dashboards logic
* withChanges true if there where changes made while in panel edit
*/
public editingCompleted(withChanges: boolean) {
if (withChanges) {
this._prevRepeatValues = undefined;
}
if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) {
this.setState({ width: GRID_COLUMN_COUNT });
}
}
public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) {
for (const panel of this.state.repeatedPanels ?? []) {
const queryRunner = getQueryRunnerFor(panel);
if (queryRunner) {
queryRunner.variableDependency?.variableUpdateCompleted(variable, false);
}
}
}
public getMaxPerRow(): number {
return this.state.maxPerRow ?? 4;
}
public setMaxPerRow(maxPerRow: number | undefined) {
this.setState({ maxPerRow });
}
public getRepeatDirection(): RepeatDirection {
return this.state.repeatDirection === 'v' ? 'v' : 'h';
}
public getClassName() {
return this.state.variableName ? 'panel-repeater-grid-item' : '';
public setRepeatDirection(repeatDirection: RepeatDirection) {
this.setState({ repeatDirection });
}
public isRepeated() {
public isRepeated(): boolean {
return this.state.variableName !== undefined;
}
public static Component = ({ model }: SceneComponentProps<DashboardGridItem>) => {
const { repeatedPanels, itemHeight, variableName, body } = model.useState();
const itemCount = repeatedPanels?.length ?? 0;
const layoutStyle = useLayoutStyle(model.getRepeatDirection(), itemCount, model.getMaxPerRow(), itemHeight ?? 10);
if (!variableName) {
if (body instanceof VizPanel) {
return <body.Component model={body} key={body.state.key} />;
}
}
if (!repeatedPanels) {
return null;
}
return (
<div className={layoutStyle}>
{repeatedPanels.map((panel) => (
<div className={itemStyle} key={panel.state.key}>
<panel.Component model={panel} key={panel.state.key} />
</div>
))}
</div>
);
};
}
export class DashboardGridItemVariableDependencyHandler implements SceneVariableDependencyConfigLike {
constructor(private _gridItem: DashboardGridItem) {}
getNames(): Set<string> {
if (this._gridItem.state.variableName) {
return new Set([this._gridItem.state.variableName]);
}
return new Set();
}
hasDependencyOn(name: string): boolean {
return this._gridItem.state.variableName === name;
}
variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean): void {
if (this._gridItem.state.variableName === variable.state.name) {
/**
* We do not really care if the variable has changed or not as we do an equality check in performRepeat
* And this function needs to be called even when variable valued id not change as performRepeat calls
* notifyRepeatedPanelsWaitingForVariables which is needed to notify panels waiting for variable to complete (even when the value did not change)
* This is for scenarios where the variable used for repeating is depending on time range.
*/
this._gridItem.performRepeat();
}
}
}
function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) {
return useMemo(() => {
const theme = config.theme2;
// In mobile responsive layout we have to calculate the absolute height
const mobileHeight = itemHeight * GRID_CELL_HEIGHT * itemCount + (itemCount - 1) * GRID_CELL_VMARGIN;
if (direction === 'h') {
const rowCount = Math.ceil(itemCount / maxPerRow);
const columnCount = Math.min(itemCount, maxPerRow);
return css({
display: 'grid',
height: '100%',
width: '100%',
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: `repeat(${rowCount}, 1fr)`,
gridColumnGap: theme.spacing(1),
gridRowGap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
display: 'flex',
flexDirection: 'column',
height: mobileHeight,
},
});
}
// Vertical is a bit simpler
return css({
display: 'flex',
height: '100%',
width: '100%',
flexDirection: 'column',
gap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
height: mobileHeight,
},
});
}, [direction, itemCount, maxPerRow, itemHeight]);
}
const itemStyle = css({
display: 'flex',
flexGrow: 1,
position: 'relative',
});

View File

@ -7,7 +7,7 @@ import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSel
import { DashboardGridItem } from './DashboardGridItem';
export function getDashboardGridItemOptions(gridItem: DashboardGridItem) {
export function getDashboardGridItemOptions(gridItem: DashboardGridItem): OptionsPaneCategoryDescriptor {
const category = new OptionsPaneCategoryDescriptor({
title: t('dashboard.default-layout.item-options.repeat.title', 'Repeat options'),
id: 'Repeat options',
@ -66,20 +66,23 @@ function RepeatDirectionOption({ gridItem }: OptionComponentProps) {
<RadioButtonGroup
options={directionOptions}
value={repeatDirection ?? 'h'}
onChange={(value) => gridItem.setState({ repeatDirection: value })}
onChange={(value) => gridItem.setRepeatDirection(value)}
/>
);
}
function MaxPerRowOption({ gridItem }: OptionComponentProps) {
const { maxPerRow } = gridItem.useState();
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
const maxPerRowOptions: Array<SelectableValue<number>> = [2, 3, 4, 6, 8, 12].map((value) => ({
label: value.toString(),
value,
}));
return (
<Select
options={maxPerRowOptions}
value={maxPerRow ?? 4}
onChange={(value) => gridItem.setState({ maxPerRow: value.value })}
onChange={(value) => gridItem.setMaxPerRow(value.value)}
/>
);
}

View File

@ -0,0 +1,82 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { config } from '@grafana/runtime';
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
const { repeatedPanels, itemHeight, variableName, body } = model.useState();
const itemCount = repeatedPanels?.length ?? 0;
const layoutStyle = useLayoutStyle(model.getRepeatDirection(), itemCount, model.getMaxPerRow(), itemHeight ?? 10);
if (!variableName) {
if (body instanceof VizPanel) {
return <body.Component model={body} key={body.state.key} />;
}
}
if (!repeatedPanels) {
return null;
}
return (
<div className={layoutStyle}>
{repeatedPanels.map((panel) => (
<div className={itemStyle} key={panel.state.key}>
<panel.Component model={panel} key={panel.state.key} />
</div>
))}
</div>
);
}
function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) {
return useMemo(() => {
const theme = config.theme2;
// In mobile responsive layout we have to calculate the absolute height
const mobileHeight = itemHeight * GRID_CELL_HEIGHT * itemCount + (itemCount - 1) * GRID_CELL_VMARGIN;
if (direction === 'h') {
const rowCount = Math.ceil(itemCount / maxPerRow);
const columnCount = Math.min(itemCount, maxPerRow);
return css({
display: 'grid',
height: '100%',
width: '100%',
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: `repeat(${rowCount}, 1fr)`,
gridColumnGap: theme.spacing(1),
gridRowGap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
display: 'flex',
flexDirection: 'column',
height: mobileHeight,
},
});
}
// Vertical is a bit simpler
return css({
display: 'flex',
height: '100%',
width: '100%',
flexDirection: 'column',
gap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
height: mobileHeight,
},
});
}, [direction, itemCount, maxPerRow, itemHeight]);
}
const itemStyle = css({
display: 'flex',
flexGrow: 1,
position: 'relative',
});

View File

@ -0,0 +1,31 @@
import { SceneVariable, SceneVariableDependencyConfigLike } from '@grafana/scenes';
import { DashboardGridItem } from './DashboardGridItem';
export class DashboardGridItemVariableDependencyHandler implements SceneVariableDependencyConfigLike {
constructor(private _gridItem: DashboardGridItem) {}
public getNames(): Set<string> {
if (this._gridItem.state.variableName) {
return new Set([this._gridItem.state.variableName]);
}
return new Set();
}
public hasDependencyOn(name: string): boolean {
return this._gridItem.state.variableName === name;
}
public variableUpdateCompleted(variable: SceneVariable) {
if (this._gridItem.state.variableName === variable.state.name) {
/**
* We do not really care if the variable has changed or not as we do an equality check in performRepeat
* And this function needs to be called even when variable valued id not change as performRepeat calls
* notifyRepeatedPanelsWaitingForVariables which is needed to notify panels waiting for variable to complete (even when the value did not change)
* This is for scenarios where the variable used for repeating is depending on time range.
*/
this._gridItem.performRepeat();
}
}
}

View File

@ -36,13 +36,12 @@ interface DefaultGridLayoutManagerState extends SceneObjectState {
grid: SceneGridLayout;
}
/**
* State manager for the default grid layout
*/
export class DefaultGridLayoutManager
extends SceneObjectBase<DefaultGridLayoutManagerState>
implements DashboardLayoutManager
{
public static Component = DefaultGridLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public static readonly descriptor = {
@ -58,23 +57,7 @@ export class DefaultGridLayoutManager
public readonly descriptor = DefaultGridLayoutManager.descriptor;
public editModeChanged(isEditing: boolean): void {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
forceRenderChildren(this.state.grid, true);
};
if (config.featureToggles.dashboardNewLayouts) {
// We do this in a timeout to wait a bit with enabling dragging as dragging enables grid animations
// if we show the edit pane without animations it opens much faster and feels more responsive
setTimeout(updateResizeAndDragging, 10);
return;
}
updateResizeAndDragging();
}
public addPanel(vizPanel: VizPanel): void {
public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this);
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
@ -94,61 +77,7 @@ export class DefaultGridLayoutManager
});
}
/**
* Adds a new empty row
*/
public addNewRow(): SceneGridRow {
const id = dashboardSceneGraph.getNextPanelId(this);
const row = new SceneGridRow({
key: getVizPanelKeyForPanelId(id),
title: 'Row title',
actions: new RowActions({}),
y: 0,
});
const sceneGridLayout = this.state.grid;
// find all panels until the first row and put them into the newly created row. If there are no other rows,
// add all panels to the row. If there are no panels just create an empty row
const indexTillNextRow = sceneGridLayout.state.children.findIndex((child) => child instanceof SceneGridRow);
const rowChildren = sceneGridLayout.state.children
.splice(0, indexTillNextRow === -1 ? sceneGridLayout.state.children.length : indexTillNextRow)
.map((child) => child.clone());
if (rowChildren) {
row.setState({ children: rowChildren });
}
sceneGridLayout.setState({ children: [row, ...sceneGridLayout.state.children] });
return row;
}
/**
* Removes a row
* @param row
* @param removePanels
*/
public removeRow(row: SceneGridRow, removePanels = false) {
const sceneGridLayout = this.state.grid;
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 });
}
/**
* Removes a panel
*/
public removePanel(panel: VizPanel): void {
public removePanel(panel: VizPanel) {
const gridItem = panel.parent!;
if (!(gridItem instanceof DashboardGridItem)) {
@ -176,7 +105,7 @@ export class DefaultGridLayoutManager
});
}
public duplicatePanel(vizPanel: VizPanel): void {
public duplicatePanel(vizPanel: VizPanel) {
const gridItem = vizPanel.parent;
if (!(gridItem instanceof DashboardGridItem)) {
console.error('Trying to duplicate a panel that is not inside a DashboardGridItem');
@ -210,7 +139,7 @@ export class DefaultGridLayoutManager
variableName: gridItem.state.variableName,
repeatDirection: gridItem.state.repeatDirection,
maxPerRow: gridItem.state.maxPerRow,
key: `grid-item-${newPanelId}`,
key: getGridItemKeyForPanelId(newPanelId),
body: new VizPanel({ ...panelState, $data: panelData, key: getVizPanelKeyForPanelId(newPanelId) }),
});
@ -234,16 +163,12 @@ export class DefaultGridLayoutManager
throw new Error('Child is not a DashboardGridItem or SceneGridRow, invalid scene');
}
if (child instanceof DashboardGridItem) {
if (child.state.body instanceof VizPanel) {
panels.push(child.state.body);
}
if (child instanceof DashboardGridItem && child.state.body instanceof VizPanel) {
panels.push(child.state.body);
} else if (child instanceof SceneGridRow) {
child.forEachChild((child) => {
if (child instanceof DashboardGridItem) {
if (child.state.body instanceof VizPanel) {
panels.push(child.state.body);
}
if (child instanceof DashboardGridItem && child.state.body instanceof VizPanel) {
panels.push(child.state.body);
}
});
}
@ -252,29 +177,51 @@ export class DefaultGridLayoutManager
return panels;
}
public collapseAllRows(): void {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
}
if (!child.state.isCollapsed) {
this.state.grid.toggleRow(child);
}
public addNewRow(): SceneGridRow {
const id = dashboardSceneGraph.getNextPanelId(this);
const row = new SceneGridRow({
key: getVizPanelKeyForPanelId(id),
title: 'Row title',
actions: new RowActions({}),
y: 0,
});
const sceneGridLayout = this.state.grid;
// find all panels until the first row and put them into the newly created row. If there are no other rows,
// add all panels to the row. If there are no panels just create an empty row
const indexTillNextRow = sceneGridLayout.state.children.findIndex((child) => child instanceof SceneGridRow);
const rowChildren = sceneGridLayout.state.children
.splice(0, indexTillNextRow === -1 ? sceneGridLayout.state.children.length : indexTillNextRow)
.map((child) => child.clone());
if (rowChildren) {
row.setState({ children: rowChildren });
}
sceneGridLayout.setState({ children: [row, ...sceneGridLayout.state.children] });
return row;
}
public expandAllRows(): void {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
}
if (child.state.isCollapsed) {
this.state.grid.toggleRow(child);
}
});
public editModeChanged(isEditing: boolean) {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
forceRenderChildren(this.state.grid, true);
};
if (config.featureToggles.dashboardNewLayouts) {
// We do this in a timeout to wait a bit with enabling dragging as dragging enables grid animations
// if we show the edit pane without animations it opens much faster and feels more responsive
setTimeout(updateResizeAndDragging, 10);
return;
}
updateResizeAndDragging();
}
public activateRepeaters(): void {
public activateRepeaters() {
this.state.grid.forEachChild((child) => {
if (child instanceof DashboardGridItem && !child.isActive) {
child.activate();
@ -319,6 +266,7 @@ export class DefaultGridLayoutManager
});
childrenAcc.children.push(gridItem);
return childrenAcc;
}
@ -361,6 +309,7 @@ export class DefaultGridLayoutManager
}
childrenAcc.children.push(child.clone());
return childrenAcc;
},
{ panelId: 0, children: [] }
@ -369,20 +318,50 @@ export class DefaultGridLayoutManager
});
}
/**
* Handle switching to the manual grid layout from other layouts
* @param currentLayout
* @returns
*/
public removeRow(row: SceneGridRow, removePanels = false) {
const sceneGridLayout = this.state.grid;
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 collapseAllRows() {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
}
if (!child.state.isCollapsed) {
this.state.grid.toggleRow(child);
}
});
}
public expandAllRows() {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
}
if (child.state.isCollapsed) {
this.state.grid.toggleRow(child);
}
});
}
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
const panels = currentLayout.getVizPanels();
return DefaultGridLayoutManager.fromVizPanels(panels);
}
/**
* For simple test grids
* @param panels
*/
public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager {
const children: DashboardGridItem[] = [];
const panelHeight = 10;
@ -395,7 +374,7 @@ export class DefaultGridLayoutManager
children.push(
new DashboardGridItem({
key: `griditem-${getPanelIdForVizPanel(panel)}`,
key: getGridItemKeyForPanelId(getPanelIdForVizPanel(panel)),
x: currentX,
y: currentY,
width: panelWidth,
@ -421,13 +400,6 @@ export class DefaultGridLayoutManager
});
}
/**
* Useful for preserving items positioning when switching layouts
* @param gridItems
* @param isDraggable
* @param isResizable
* @returns
*/
public static fromGridItems(
gridItems: SceneGridItemLike[],
isDraggable?: boolean,
@ -448,18 +420,18 @@ export class DefaultGridLayoutManager
}),
});
}
public static Component = ({ model }: SceneComponentProps<DefaultGridLayoutManager>) => {
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = getDashboardSceneFor(model);
// If we are top level layout and have no children, show empty state
if (model.parent === dashboard && children.length === 0) {
return (
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />
);
}
return <model.state.grid.Component model={model.state.grid} />;
};
}
function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<DefaultGridLayoutManager>) {
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = getDashboardSceneFor(model);
// If we are top level layout and have no children, show empty state
if (model.parent === dashboard && children.length === 0) {
return (
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />
);
}
return <model.state.grid.Component model={model.state.grid} />;
}

View File

@ -60,7 +60,7 @@ describe('RowRepeaterBehavior', () => {
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.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-1'));
expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1'));
const row2 = grid.state.children[2] as SceneGridRow;
@ -70,7 +70,7 @@ describe('RowRepeaterBehavior', () => {
expect(row2.state.actions).toBeUndefined();
const gridItemRow2 = row2.state.children[0] as SceneGridItem;
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1'));
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-1'));
expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1'));
});
@ -98,7 +98,7 @@ describe('RowRepeaterBehavior', () => {
y: 16,
width: 24,
height: 5,
key: 'griditem-4',
key: 'grid-item-4',
body: new SceneCanvasText({
text: 'new panel',
}),
@ -235,7 +235,7 @@ function buildScene(
$behaviors: [repeatBehavior],
children: [
new SceneGridItem({
key: 'griditem-1',
key: 'grid-item-1',
x: 0,
y: 11,
width: 24,
@ -256,7 +256,7 @@ function buildScene(
title: 'Row at the bottom',
children: [
new SceneGridItem({
key: 'griditem-2',
key: 'grid-item-2',
x: 0,
y: 17,
body: new SceneCanvasText({
@ -265,7 +265,7 @@ function buildScene(
}),
}),
new SceneGridItem({
key: 'griditem-3',
key: 'grid-item-3',
x: 0,
y: 25,
body: new SceneCanvasText({

View File

@ -31,10 +31,6 @@ interface RowRepeaterBehaviorState extends SceneObjectState {
variableName: string;
}
/**
* This behavior will run an effect function when specified variables change
*/
export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorState> {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName],

View File

@ -1,32 +1,18 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
sceneGraph,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
VizPanel,
} from '@grafana/scenes';
import { Icon, TextLink, useStyles2 } from '@grafana/ui';
import { sceneGraph, SceneGridRow, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
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 { t } from 'app/core/internationalization';
import { ShowConfirmModalEvent } from 'app/types/events';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
import { DashboardScene } from '../../DashboardScene';
import { DashboardGridItem } from '../DashboardGridItem';
import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { RowOptionsButton } from './RowOptionsButton';
import { RowActionsRenderer } from './RowActionsRenderer';
export interface RowActionsState extends SceneObjectState {}
export class RowActions extends SceneObjectBase<RowActionsState> {
static Component = RowActionsRenderer;
public getParent(): SceneGridRow {
if (!(this.parent instanceof SceneGridRow)) {
throw new Error('RowActions must have a SceneGridRow parent');
@ -35,16 +21,12 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
return this.parent;
}
public getDashboard(): DashboardScene {
return getDashboardSceneFor(this);
}
public removeRow(removePanels?: boolean) {
const manager = sceneGraph.getAncestor(this, DefaultGridLayoutManager);
manager.removeRow(this.getParent(), removePanels);
}
public onUpdate = (title: string, repeat?: string | null): void => {
public onUpdate(title: string, repeat: string | null | undefined) {
const row = this.getParent();
let repeatBehavior: RowRepeaterBehavior | undefined;
@ -61,8 +43,7 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
}
if (repeat) {
// Remove repeat behavior if it exists
// to retrigger repeat when adding new one
// Remove repeat behavior if it exists to re-trigger repeat when adding new one
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
@ -72,9 +53,9 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
} else {
repeatBehavior?.removeBehavior();
}
};
}
public onDelete = () => {
public onDelete() {
appEvents.publish(
new ShowConfirmModalEvent({
title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'),
@ -88,106 +69,5 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
onAltAction: () => this.removeRow(),
})
);
};
public getWarning = () => {
const row = this.getParent();
const gridItems = row.state.children;
const isAnyPanelUsingDashboardDS = gridItems.some((gridItem) => {
if (!(gridItem instanceof DashboardGridItem)) {
return false;
}
const vizPanel = gridItem.state.body;
if (vizPanel instanceof 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 false;
});
if (isAnyPanelUsingDashboardDS) {
return (
<div>
<p>
<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
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.learn-more">Learn more</Trans>
</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={t('dashboard.default-layout.row-actions.delete', '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,
},
},
}),
};
};

View File

@ -0,0 +1,92 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
import { Icon, useStyles2 } from '@grafana/ui';
import { t } 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 { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
import { DashboardGridItem } from '../DashboardGridItem';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { RowActions } from './RowActions';
import { RowOptionsButton } from './RowOptionsButton';
export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
const dashboard = getDashboardSceneFor(model);
const row = model.getParent();
const { title, children } = row.useState();
const { meta, isEditing } = dashboard.useState();
const styles = useStyles2(getStyles);
const isUsingDashboardDS = useMemo(
() =>
children.some((gridItem) => {
if (!(gridItem instanceof DashboardGridItem)) {
return false;
}
if (gridItem.state.body instanceof VizPanel) {
const runner = getQueryRunnerFor(gridItem.state.body);
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;
}),
[children]
);
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={(title, repeat) => model.onUpdate(title, repeat)}
isUsingDashboardDS={isUsingDashboardDS}
/>
<button
type="button"
onClick={() => model.onDelete()}
aria-label={t('dashboard.default-layout.row-actions.delete', '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,
},
},
}),
};
};

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Icon, ModalsController } from '@grafana/ui';
import { t } from 'app/core/internationalization';
@ -12,15 +10,10 @@ export interface RowOptionsButtonProps {
repeat?: string;
parent: SceneObject;
onUpdate: OnRowOptionsUpdate;
warning?: React.ReactNode;
isUsingDashboardDS: boolean;
}
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: RowOptionsButtonProps) => {
const onUpdateChange = (hideModal: () => void) => (title: string, repeat?: string | null) => {
onUpdate(title, repeat);
hideModal();
};
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, isUsingDashboardDS }: RowOptionsButtonProps) => {
return (
<ModalsController>
{({ showModal, hideModal }) => {
@ -35,8 +28,11 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R
repeat,
parent,
onDismiss: hideModal,
onUpdate: onUpdateChange(hideModal),
warning,
onUpdate: (title: string, repeat?: string | null) => {
onUpdate(title, repeat);
hideModal();
},
isUsingDashboardDS,
});
}}
>

View File

@ -10,6 +10,7 @@ import { RowOptionsForm } from './RowOptionsForm';
jest.mock('app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect', () => ({
RepeatRowSelect2: () => <div />,
}));
describe('DashboardRow', () => {
const scene = new DashboardScene({
title: 'hello',
@ -28,7 +29,7 @@ describe('DashboardRow', () => {
title=""
onCancel={jest.fn()}
onUpdate={jest.fn()}
warning="a warning message"
isUsingDashboardDS={true}
/>
</TestProvider>
);
@ -40,7 +41,14 @@ describe('DashboardRow', () => {
it('Should not show warning component when does not have warningMessage prop', () => {
render(
<TestProvider>
<RowOptionsForm repeat={'3'} sceneContext={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} />
<RowOptionsForm
repeat={'3'}
sceneContext={scene}
title=""
onCancel={jest.fn()}
onUpdate={jest.fn()}
isUsingDashboardDS={false}
/>
</TestProvider>
);
expect(

View File

@ -1,12 +1,12 @@
import { useCallback, useState } from 'react';
import * as React 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 { Button, Field, Modal, Input, Alert, TextLink } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
@ -16,17 +16,15 @@ export interface Props {
sceneContext: SceneObject;
onUpdate: OnRowOptionsUpdate;
onCancel: () => void;
warning?: React.ReactNode;
isUsingDashboardDS: boolean;
}
export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate, onCancel }: Props) => {
export const RowOptionsForm = ({ repeat, title, sceneContext, isUsingDashboardDS, onUpdate, onCancel }: Props) => {
const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat);
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]);
const { handleSubmit, register } = useForm({
defaultValues: {
title,
},
defaultValues: { title },
});
const submit = (formData: { title: string }) => {
@ -38,10 +36,10 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
<Field label={t('dashboard.default-layout.row-options.form.title', 'Title')}>
<Input {...register('title')} type="text" />
</Field>
<Field label={t('dashboard.default-layout.row-options.form.repeat-for', 'Repeat for')}>
<Field label={t('dashboard.default-layout.row-options.form.repeat-for.label', 'Repeat for')}>
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
</Field>
{warning && (
{isUsingDashboardDS && (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
@ -49,7 +47,22 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
topSpacing={3}
bottomSpacing={0}
>
{warning}
<div>
<p>
<Trans i18nKey="dashboard.default-layout.row-options.form.repeat-for.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
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
<Trans i18nKey="dashboard.default-layout.row-options.form.repeat-for.learn-more">Learn more</Trans>
</TextLink>
</div>
</Alert>
)}
<Modal.ButtonRow>

View File

@ -1,5 +1,4 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Modal, useStyles2 } from '@grafana/ui';
@ -11,12 +10,19 @@ export interface RowOptionsModalProps {
title: string;
repeat?: string;
parent: SceneObject;
warning?: React.ReactNode;
isUsingDashboardDS: boolean;
onDismiss: () => void;
onUpdate: OnRowOptionsUpdate;
}
export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, warning }: RowOptionsModalProps) => {
export const RowOptionsModal = ({
repeat,
title,
parent,
onDismiss,
onUpdate,
isUsingDashboardDS,
}: RowOptionsModalProps) => {
const styles = useStyles2(getStyles);
return (
@ -32,7 +38,7 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa
title={title}
onCancel={onDismiss}
onUpdate={onUpdate}
warning={warning}
isUsingDashboardDS={isUsingDashboardDS}
/>
</Modal>
);

View File

@ -1,87 +1,26 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes';
import { Switch, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { SceneObjectState, VizPanel, SceneObjectBase } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { getOptions } from './ResponsiveGridItemEditor';
import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer';
export interface ResponsiveGridItemState extends SceneObjectState {
body: VizPanel;
hideWhenNoData?: boolean;
}
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem {
public static Component = ResponsiveGridItemRenderer;
public readonly isDashboardLayoutItem = true;
public constructor(state: ResponsiveGridItemState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
if (!this.state.hideWhenNoData) {
return;
}
public getOptions(): OptionsPaneCategoryDescriptor {
return getOptions(this);
}
public toggleHideWhenNoData() {
this.setState({ hideWhenNoData: !this.state.hideWhenNoData });
}
public getOptions?(): OptionsPaneCategoryDescriptor {
const model = this;
const category = new OptionsPaneCategoryDescriptor({
title: t('dashboard.responsive-layout.item-options.title', 'Layout options'),
id: 'layout-options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.item-options.hide-no-data', 'Hide when no data'),
render: function renderTransparent() {
const { hideWhenNoData } = model.useState();
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => model.toggleHideWhenNoData()} />;
},
})
);
return category;
}
public setBody(body: SceneObject): void {
if (body instanceof VizPanel) {
this.setState({ body });
}
}
public getVizPanel() {
return this.state.body;
}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridItem>) => {
const { body } = model.useState();
const style = useStyles2(getStyles);
return (
<div className={cx(style.wrapper)}>
<body.Component model={body} />
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
};
}

View File

@ -0,0 +1,29 @@
import { Switch } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { ResponsiveGridItem } from './ResponsiveGridItem';
export function getOptions(model: ResponsiveGridItem): OptionsPaneCategoryDescriptor {
const category = new OptionsPaneCategoryDescriptor({
title: t('dashboard.responsive-layout.item-options.title', 'Layout options'),
id: 'layout-options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.item-options.hide-no-data', 'Hide when no data'),
render: () => <GridItemNoDataToggle item={model} />,
})
);
return category;
}
function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) {
const { hideWhenNoData } = item.useState();
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => item.toggleHideWhenNoData()} />;
}

View File

@ -0,0 +1,27 @@
import { css, cx } from '@emotion/css';
import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { ResponsiveGridItem } from './ResponsiveGridItem';
export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<ResponsiveGridItem>) {
const { body } = model.useState();
const style = useStyles2(getStyles);
return (
<div className={cx(style.wrapper)}>
<body.Component model={body} />
</div>
);
}
function getStyles() {
return {
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
};
}

View File

@ -1,6 +1,4 @@
import { SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Select } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -10,6 +8,7 @@ import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { ResponsiveGridItem } from './ResponsiveGridItem';
import { getEditOptions } from './ResponsiveGridLayoutManagerEditor';
interface ResponsiveGridLayoutManagerState extends SceneObjectState {
layout: SceneCSSGridLayout;
@ -19,6 +18,8 @@ export class ResponsiveGridLayoutManager
extends SceneObjectBase<ResponsiveGridLayoutManagerState>
implements DashboardLayoutManager
{
public static Component = ResponsiveGridLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public static readonly descriptor = {
@ -37,13 +38,11 @@ export class ResponsiveGridLayoutManager
public constructor(state: ResponsiveGridLayoutManagerState) {
super(state);
//@ts-ignore
// @ts-ignore
this.state.layout.getDragClassCancel = () => 'drag-cancel';
}
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {
public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this);
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
@ -54,18 +53,12 @@ export class ResponsiveGridLayoutManager
});
}
public addNewRow(): void {
const rowsLayout = RowsLayoutManager.createFromLayout(this);
rowsLayout.addNewRow();
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public removePanel(panel: VizPanel) {
const element = panel.parent;
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
}
public duplicatePanel(panel: VizPanel): void {
public duplicatePanel(panel: VizPanel) {
const gridItem = panel.parent;
if (!(gridItem instanceof ResponsiveGridItem)) {
console.error('Trying to duplicate a panel that is not inside a DashboardGridItem');
@ -103,11 +96,25 @@ export class ResponsiveGridLayoutManager
return panels;
}
public getOptions(): OptionsPaneItemDescriptor[] {
return getOptions(this);
public addNewRow() {
const rowsLayout = RowsLayoutManager.createFromLayout(this);
rowsLayout.addNewRow();
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public static createEmpty() {
public getOptions(): OptionsPaneItemDescriptor[] {
return getEditOptions(this);
}
public changeColumns(columns: string) {
this.state.layout.setState({ templateColumns: columns });
}
public changeRows(rows: string) {
this.state.layout.setState({ autoRows: rows });
}
public static createEmpty(): ResponsiveGridLayoutManager {
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
children: [],
@ -125,96 +132,13 @@ export class ResponsiveGridLayoutManager
children.push(new ResponsiveGridItem({ body: panel.clone() }));
}
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
children,
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
}),
});
const layoutManager = ResponsiveGridLayoutManager.createEmpty();
layoutManager.state.layout.setState({ children });
return layoutManager;
}
/**
* Might as well implement this as a no-top function as vs-code very eagerly adds optional functions that throw Method not implemented
*/
public activateRepeaters(): void {}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return <model.state.layout.Component model={model.state.layout} />;
};
}
function getOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
const cssLayout = layoutManager.state.layout;
const rowOptions: Array<SelectableValue<string>> = [];
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
const colOptions: Array<SelectableValue<string>> = [
{ label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
{ label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
{ label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
];
for (const size of sizes) {
colOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `repeat(auto-fit, minmax(${size}px, auto))`,
});
}
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `minmax(${size}px, auto)`,
});
}
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
value: `${size}px`,
});
}
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.columns', 'Columns'),
render: () => {
const { templateColumns } = cssLayout.useState();
return (
<Select
options={colOptions}
value={String(templateColumns)}
onChange={(value) => {
cssLayout.setState({ templateColumns: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.rows', 'Rows'),
render: () => {
const { autoRows } = cssLayout.useState();
return (
<Select
options={rowOptions}
value={String(autoRows)}
onChange={(value) => {
cssLayout.setState({ autoRows: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return options;
function ResponsiveGridLayoutManagerRenderer({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) {
return <model.state.layout.Component model={model.state.layout} />;
}

View File

@ -0,0 +1,83 @@
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.columns', 'Columns'),
render: () => <GridLayoutColumns layoutManager={layoutManager} />,
})
);
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.rows', 'Rows'),
render: () => <GridLayoutRows layoutManager={layoutManager} />,
})
);
return options;
}
function GridLayoutColumns({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
const { templateColumns } = layoutManager.state.layout.useState();
const colOptions: Array<SelectableValue<string>> = [
{ label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
{ label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
{ label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
];
for (const size of sizes) {
colOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `repeat(auto-fit, minmax(${size}px, auto))`,
});
}
return (
<Select
options={colOptions}
value={String(templateColumns)}
onChange={({ value }) => layoutManager.changeColumns(value!)}
allowCustomValue={true}
/>
);
}
function GridLayoutRows({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
const { autoRows } = layoutManager.state.layout.useState();
const rowOptions: Array<SelectableValue<string>> = [];
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `minmax(${size}px, auto)`,
});
}
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
value: `${size}px`,
});
}
return (
<Select
options={rowOptions}
value={String(autoRows)}
onChange={({ value }) => layoutManager.changeRows(value!)}
allowCustomValue={true}
/>
);
}

View File

@ -1,89 +0,0 @@
import { ReactNode, useMemo } from 'react';
import { SceneObject } from '@grafana/scenes';
import { Button, Stack, Switch, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
import { RowItem } from './RowItem';
export class MultiSelectedRowItemsElement implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Rows';
private items?: RowItem[];
constructor(items: SceneObject[]) {
this.items = [];
for (const item of items) {
if (item instanceof RowItem) {
this.items.push(item);
}
}
}
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const rows = this.items;
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'),
id: 'ms-row-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.row.hide', 'Hide row header'),
render: () => <RowHeaderSwitch rows={rows} />,
})
);
}, [rows]);
return [rowOptions];
}
public onDelete = () => {
for (const item of this.items || []) {
item.onDelete();
}
};
renderActions(): ReactNode {
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number">No. of rows selected: </Trans>
{this.items?.length}
</Text>
<Stack direction="row">
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
</Stack>
</Stack>
);
}
}
export function RowHeaderSwitch({ rows }: { rows: RowItem[] | undefined }) {
if (!rows) {
return null;
}
const { isHeaderHidden = false } = rows[0].useState();
return (
<Switch
value={isHeaderHidden}
onChange={() => {
for (const row of rows) {
row.setState({
isHeaderHidden: !isHeaderHidden,
});
}
}}
/>
);
}

View File

@ -1,45 +1,19 @@
import { css, cx } from '@emotion/css';
import { ReactNode, useMemo, useRef } from 'react';
import { ReactNode } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
sceneGraph,
VariableDependencyConfig,
SceneObject,
} from '@grafana/scenes';
import {
Alert,
Button,
Icon,
Input,
RadioButtonGroup,
Switch,
TextLink,
useElementSelection,
useStyles2,
} from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
import { t } 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 { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { EditableDashboardElement } from '../types/EditableDashboardElement';
import { LayoutParent } from '../types/LayoutParent';
import { MultiSelectedRowItemsElement } from './MultiSelectedRowItemsElement';
import { getEditOptions, renderActions } from './RowItemEditor';
import { RowItemRenderer } from './RowItemRenderer';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowItems } from './RowItems';
import { RowsLayoutManager } from './RowsLayoutManager';
export interface RowItemState extends SceneObjectState {
@ -54,6 +28,8 @@ export class RowItem
extends SceneObjectBase<RowItemState>
implements LayoutParent, BulkActionElement, EditableDashboardElement
{
public static Component = RowItemRenderer;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['title'],
});
@ -61,303 +37,77 @@ export class RowItem
public readonly isEditableDashboardElement = true;
public readonly typeName = 'Row';
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const row = this;
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.title', 'Row options'),
id: 'row-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.title-option', 'Title'),
render: () => <RowTitleInput row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.height.title', 'Height'),
render: () => <RowHeightSelect row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.height.hide-row-header', 'Hide row header'),
render: () => <RowHeaderSwitch row={row} />,
})
);
}, [row]);
const rowRepeatOptions = useMemo(() => {
const dashboard = getDashboardSceneFor(row);
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.title', 'Repeat options'),
id: 'row-repeat-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Variable'),
render: () => <RowRepeatSelect row={row} dashboard={dashboard} />,
})
);
}, [row]);
const { layout } = this.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, rowRepeatOptions, layoutOptions];
}
public createMultiSelectedElement(items: SceneObject[]) {
return new MultiSelectedRowItemsElement(items);
}
public onDelete = () => {
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
layout.removeRow(this);
};
public renderActions(): ReactNode {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
</>
);
public constructor(state?: Partial<RowItemState>) {
super({
...state,
title: state?.title ?? t('dashboard.rows-layout.row.new', 'New row'),
layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(),
});
}
public getLayout(): DashboardLayoutManager {
return this.state.layout;
}
public switchLayout(layout: DashboardLayoutManager): void {
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout });
}
public onCollapseToggle = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
return getEditOptions(this);
}
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
this.getLayout().addPanel(vizPanel);
};
public renderActions(): ReactNode {
return renderActions(this);
}
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = 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);
const shouldGrow = !isCollapsed && height === 'expand';
const { isSelected, onSelect } = useElementSelection(key);
public onDelete() {
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
layout.removeRow(this);
}
return (
<div
className={cx(
styles.wrapper,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
!isClone && isSelected && 'dashboard-selected-element'
)}
ref={ref}
>
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
<div className={styles.rowHeader}>
<button
onClick={model.onCollapseToggle}
className={styles.rowTitleButton}
aria-label={
isCollapsed
? t('dashboard.rows-layout.row.expand', 'Expand row')
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
}
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{!isClone && isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
</div>
)}
{!isCollapsed && <layout.Component model={layout} />}
</div>
);
};
}
public createMultiSelectedElement(items: SceneObject[]): RowItems {
return new RowItems(items.filter((item) => item instanceof RowItem));
}
function getStyles(theme: GrafanaTheme2) {
return {
rowHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 0, 0.5, 0),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'center',
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
public onChangeTitle(title: string) {
this.setState({ title });
}
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
rowTitleButton: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
minWidth: 0,
gap: theme.spacing(1),
}),
rowTitle: css({
fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
flexGrow: 1,
minWidth: 0,
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '100%',
minHeight: '100px',
}),
wrapperGrow: css({
flexGrow: 1,
}),
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
minHeight: 'unset',
}),
rowActions: css({
display: 'flex',
opacity: 0,
}),
};
}
public onHeaderHiddenToggle(isHeaderHidden = !this.state.isHeaderHidden) {
this.setState({ isHeaderHidden });
}
export function RowTitleInput({ row }: { row: RowItem }) {
const { title } = row.useState();
public onChangeHeight(height: 'expand' | 'min') {
this.setState({ height });
}
return <Input value={title} onChange={(e) => row.setState({ title: e.currentTarget.value })} />;
}
public onChangeRepeat(repeat: string | undefined) {
let repeatBehavior = this._getRepeatBehavior();
export function RowHeaderSwitch({ row }: { row: RowItem }) {
const { isHeaderHidden = false } = row.useState();
return (
<Switch
value={isHeaderHidden}
onChange={() => {
row.setState({
isHeaderHidden: !row.state.isHeaderHidden,
});
}}
/>
);
}
export function RowHeightSelect({ row }: { row: RowItem }) {
const { height = 'expand' } = row.useState();
const options: Array<SelectableValue<'expand' | 'min'>> = [
{ label: t('dashboard.rows-layout.row-options.height.expand', 'Expand'), value: 'expand' },
{ label: t('dashboard.rows-layout.row-options.height.min', 'Min'), value: 'min' },
];
return (
<RadioButtonGroup
options={options}
value={height}
onChange={(option) =>
row.setState({
height: option,
})
if (repeat) {
// Remove repeat behavior if it exists to trigger repeat when adding new one
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
/>
);
}
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}
</>
);
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
repeatBehavior.activate();
} else {
repeatBehavior?.removeBehavior();
}
}
public onCollapseToggle() {
this.setState({ isCollapsed: !this.state.isCollapsed });
}
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
}
}

View File

@ -0,0 +1,144 @@
import { useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Alert, Button, Input, RadioButtonGroup, Switch, TextLink } from '@grafana/ui';
import { t, 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, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { RowItem } from './RowItem';
export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[] {
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.title', 'Row options'),
id: 'row-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.title-option', 'Title'),
render: () => <RowTitleInput row={model} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.height.title', 'Height'),
render: () => <RowHeightSelect row={model} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.height.hide-row-header', 'Hide row header'),
render: () => <RowHeaderSwitch row={model} />,
})
);
}, [model]);
const rowRepeatOptions = useMemo(() => {
const dashboard = getDashboardSceneFor(model);
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.title', 'Repeat options'),
id: 'row-repeat-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Variable'),
render: () => <RowRepeatSelect row={model} dashboard={dashboard} />,
})
);
}, [model]);
const { layout } = model.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, rowRepeatOptions, layoutOptions];
}
export function renderActions(model: RowItem) {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
</>
);
}
function RowTitleInput({ row }: { row: RowItem }) {
const { title } = row.useState();
return <Input value={title} onChange={(e) => row.onChangeTitle(e.currentTarget.value)} />;
}
function RowHeaderSwitch({ row }: { row: RowItem }) {
const { isHeaderHidden = false } = row.useState();
return <Switch value={isHeaderHidden} onChange={() => row.onHeaderHiddenToggle()} />;
}
function RowHeightSelect({ row }: { row: RowItem }) {
const { height = 'expand' } = row.useState();
const options: Array<SelectableValue<'expand' | 'min'>> = [
{ label: t('dashboard.rows-layout.row-options.height.expand', 'Expand'), value: 'expand' },
{ label: t('dashboard.rows-layout.row-options.height.min', 'Min'), value: 'min' },
];
return <RadioButtonGroup options={options} value={height} onChange={(option) => row.onChangeHeight(option)} />;
}
function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
const { layout } = row.useState();
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={row.getRepeatVariable()}
onChange={(repeat) => row.onChangeRepeat(repeat)}
/>
{isAnyPanelUsingDashboardDS ? (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
title=""
topSpacing={3}
bottomSpacing={0}
>
<p>
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel
in the original row, not the ones in the repeated rows.
</Trans>
</p>
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
</TextLink>
</Alert>
) : undefined}
</>
);
}

View File

@ -0,0 +1,122 @@
import { css, cx } from '@emotion/css';
import { useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, useElementSelection, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from './RowItem';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = 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);
const shouldGrow = !isCollapsed && height === 'expand';
const { isSelected, onSelect } = useElementSelection(key);
return (
<div
className={cx(
styles.wrapper,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
!isClone && isSelected && 'dashboard-selected-element'
)}
ref={ref}
>
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
<div className={styles.rowHeader}>
<button
onClick={() => model.onCollapseToggle()}
className={styles.rowTitleButton}
aria-label={
isCollapsed
? t('dashboard.rows-layout.row.expand', 'Expand row')
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
}
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{!isClone && isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
</div>
)}
{!isCollapsed && <layout.Component model={layout} />}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
rowHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 0, 0.5, 0),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'center',
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
rowTitleButton: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
minWidth: 0,
gap: theme.spacing(1),
}),
rowTitle: css({
fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
flexGrow: 1,
minWidth: 0,
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '100%',
minHeight: '100px',
}),
wrapperGrow: css({
flexGrow: 1,
}),
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
minHeight: 'unset',
}),
rowActions: css({
display: 'flex',
opacity: 0,
}),
};
}

View File

@ -141,7 +141,7 @@ function buildScene(
$behaviors: [repeatBehavior],
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'griditem-1',
key: 'grid-item-1',
x: 0,
y: 11,
width: 24,
@ -155,13 +155,13 @@ function buildScene(
title: 'Row at the bottom',
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'griditem-2',
key: 'grid-item-2',
x: 0,
y: 17,
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
}),
new DashboardGridItem({
key: 'griditem-3',
key: 'grid-item-3',
x: 0,
y: 25,
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),

View File

@ -22,10 +22,6 @@ 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],

View File

@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
import { RowItem } from './RowItem';
import { getEditOptions, renderActions } from './RowItemsEditor';
export class RowItems implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Rows';
public readonly key: string;
public constructor(private _rows: RowItem[]) {
this.key = uuidv4();
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public getRows(): RowItem[] {
return this._rows;
}
public onDelete() {
this._rows.forEach((row) => row.onDelete());
}
public onHeaderHiddenToggle(value: boolean, indeterminate: boolean) {
this._rows.forEach((row) => row.onHeaderHiddenToggle(indeterminate ? true : !value));
}
}

View File

@ -0,0 +1,64 @@
import { Button, Checkbox, Stack, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RowItems } from './RowItems';
export function getEditOptions(model: RowItems): OptionsPaneCategoryDescriptor[] {
const options = new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'),
id: `ms-row-options-${model.key}`,
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.row.header.title', 'Row header'),
render: () => <RowHeaderCheckboxMulti model={model} />,
})
);
return [options];
}
export function renderActions(model: RowItems) {
const rows = model.getRows();
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number" values={{ length: rows.length }}>
No. of rows selected: {{ length }}
</Trans>
</Text>
<Stack direction="row">
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
</Stack>
</Stack>
);
}
function RowHeaderCheckboxMulti({ model }: { model: RowItems }) {
const rows = model.getRows();
let value = false;
let indeterminate = false;
for (let i = 0; i < rows.length; i++) {
const { isHeaderHidden } = rows[i].useState();
const prevElement = rows[i - 1];
indeterminate = indeterminate || (prevElement && !!prevElement.state.isHeaderHidden !== !!isHeaderHidden);
value = value || !!isHeaderHidden;
}
return (
<Checkbox
label={t('dashboard.edit-pane.row.header.hide', 'Hide')}
value={value}
indeterminate={indeterminate}
onChange={() => model.onHeaderHiddenToggle(value, indeterminate)}
/>
);
}

View File

@ -1,34 +1,24 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
sceneGraph,
SceneGridItemLike,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
VizPanel,
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { SceneGridItemLike, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { DashboardScene } from '../DashboardScene';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
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 } from '../types/DashboardLayoutManager';
import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
}
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
public static Component = RowLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public static readonly descriptor = {
@ -44,47 +34,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
public readonly descriptor = RowsLayoutManager.descriptor;
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {
public addPanel(vizPanel: VizPanel) {
// Try to add new panels to the selected row
const selectedObject = this.getSelectedObject();
if (selectedObject instanceof RowItem) {
return selectedObject.onAddPanel(vizPanel);
const selectedRows = dashboardSceneGraph.getAllSelectedObjects(this).filter((obj) => obj instanceof RowItem);
if (selectedRows.length > 0) {
return selectedRows.forEach((row) => row.getLayout().addPanel(vizPanel));
}
// If we don't have selected row add it to the first row
if (this.state.rows.length > 0) {
return this.state.rows[0].onAddPanel(vizPanel);
return this.state.rows[0].getLayout().addPanel(vizPanel);
}
// Otherwise fallback to adding a new row and a panel
this.addNewRow();
this.state.rows[this.state.rows.length - 1].onAddPanel(vizPanel);
}
public addNewRow(): void {
this.setState({
rows: [
...this.state.rows,
new RowItem({
title: t('dashboard.rows-layout.row.new', 'New row'),
layout: ResponsiveGridLayoutManager.createEmpty(),
}),
],
});
}
public removePanel(panel: VizPanel) {}
public removeRow(row: RowItem) {
this.setState({
rows: this.state.rows.filter((r) => r !== row),
});
}
public duplicatePanel(panel: VizPanel): void {
throw new Error('Method not implemented.');
this.state.rows[this.state.rows.length - 1].getLayout().addPanel(vizPanel);
}
public getVizPanels(): VizPanel[] {
@ -98,33 +62,38 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return panels;
}
public getOptions() {
return [];
public addNewRow() {
this.setState({ rows: [...this.state.rows, new RowItem()] });
}
public editModeChanged(isEditing: boolean) {
this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing));
}
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();
}
if (!row.isActive) {
row.activate();
}
const behavior = (row.state.$behaviors ?? []).find((b) => b instanceof RowItemRepeaterBehavior);
if (!behavior?.isActive) {
behavior?.activate();
}
row.getLayout().activateRepeaters?.();
});
}
public getSelectedObject() {
return sceneGraph.getAncestor(this, DashboardScene).state.editPane.state.selection?.getFirstObject();
public removeRow(row: RowItem) {
this.setState({
rows: this.state.rows.filter((r) => r !== row),
});
}
public static createEmpty() {
return new RowsLayoutManager({ rows: [] });
public static createEmpty(): RowsLayoutManager {
return new RowsLayoutManager({ rows: [new RowItem()] });
}
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
@ -175,7 +144,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
rows = config.map(
(rowConfig) =>
new RowItem({
title: rowConfig.title ?? t('dashboard.rows-layout.row.new', 'New row'),
title: rowConfig.title,
isCollapsed: !!rowConfig.isCollapsed,
layout: DefaultGridLayoutManager.fromGridItems(
rowConfig.children,
@ -186,34 +155,14 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
})
);
} else {
rows = [new RowItem({ layout: layout.clone(), title: t('dashboard.rows-layout.row.new', 'New row') })];
rows = [new RowItem({ layout: layout.clone() })];
}
// Ensure we always get at least one row
if (rows.length === 0) {
rows = [new RowItem()];
}
return new RowsLayoutManager({ rows });
}
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
const { rows } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<row.Component model={row} key={row.state.key!} />
))}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
flexGrow: 1,
width: '100%',
}),
};
}

View File

@ -0,0 +1,32 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { RowsLayoutManager } from './RowsLayoutManager';
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
const { rows } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<row.Component model={row} key={row.state.key!} />
))}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
flexGrow: 1,
width: '100%',
}),
};
}

View File

@ -1,5 +1,12 @@
export interface BulkActionElement {
/**
* Called when the element should be deleted
*/
onDelete(): void;
/**
* Called when the element should be copied
*/
onCopy?(): void;
}

View File

@ -25,13 +25,13 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
* Remove an element / panel
* @param panel
*/
removePanel(panel: VizPanel): void;
removePanel?(panel: VizPanel): void;
/**
* Creates a copy of an existing element and adds it to the layout
* @param panel
*/
duplicatePanel(panel: VizPanel): void;
duplicatePanel?(panel: VizPanel): void;
/**
* getVizPanels

View File

@ -6,6 +6,15 @@ import { DashboardLayoutManager } from './DashboardLayoutManager';
* This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager)
*/
export interface LayoutParent extends SceneObject {
/**
* Returns the inner layout manager
*/
getLayout(): DashboardLayoutManager;
/**
* Switches the inner layout manager
* @param newLayout
*/
switchLayout(newLayout: DashboardLayoutManager): void;
}

View File

@ -13,6 +13,11 @@ export interface MultiSelectedEditableDashboardElement {
*/
typeName: Readonly<string>;
/**
* Extremely useful for being able to access the useState inside the contained items
*/
key: Readonly<string>;
/**
* Hook that returns edit pane options
*/

View File

@ -5,7 +5,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { VizPanelLinks } from '../scene/PanelLinks';
import { isClonedKey } from './clone';
import { getLayoutManagerFor, getPanelIdForVizPanel } from './utils';
import { getDashboardSceneFor, getLayoutManagerFor, getPanelIdForVizPanel } from './utils';
function getTimePicker(scene: DashboardScene) {
return scene.state.controls?.state.timePicker;
@ -62,6 +62,14 @@ function getDataLayers(scene: DashboardScene): DashboardDataLayerSet {
return data;
}
function getAllSelectedObjects(scene: SceneObject): SceneObject[] {
return (
getDashboardSceneFor(scene)
.state.editPane.state.selection?.getSelectionEntries()
.map(([, ref]) => ref.resolve()) ?? []
);
}
export function getCursorSync(scene: DashboardScene) {
const cursorSync = scene.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
@ -78,6 +86,7 @@ export const dashboardSceneGraph = {
getPanelLinks,
getVizPanels,
getDataLayers,
getAllSelectedObjects,
getCursorSync,
getLayoutManagerFor,
getNextPanelId,

View File

@ -925,12 +925,6 @@
"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": {
@ -939,7 +933,13 @@
},
"form": {
"cancel": "Cancel",
"repeat-for": "Repeat for",
"repeat-for": {
"label": "Repeat for",
"learn-more": "Learn more",
"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."
}
},
"title": "Title",
"update": "Update"
},
@ -951,20 +951,23 @@
"edit-pane": {
"objects": {
"multi-select": {
"selection-number": "No. of objects selected: "
"selection-number": "No. of objects selected: {{length}}"
}
},
"open": "Open options pane",
"panels": {
"multi-select": {
"selection-number": "No. of panels selected: "
"selection-number": "No. of panels selected: {{length}}"
}
},
"row": {
"hide": "Hide row header",
"header": {
"hide": "Hide",
"title": "Row header"
},
"multi-select": {
"options-header": "Multi-selected Row options",
"selection-number": "No. of rows selected: "
"selection-number": "No. of rows selected: {{length}}"
}
}
},

View File

@ -925,12 +925,6 @@
"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": {
@ -939,7 +933,13 @@
},
"form": {
"cancel": "Cäʼnčęľ",
"repeat-for": "Ŗępęäŧ ƒőř",
"repeat-for": {
"label": "Ŗępęäŧ ƒőř",
"learn-more": "Ŀęäřʼn mőřę",
"warning": {
"text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
}
},
"title": "Ŧįŧľę",
"update": "Ůpđäŧę"
},
@ -951,20 +951,23 @@
"edit-pane": {
"objects": {
"multi-select": {
"selection-number": "Ńő. őƒ őþĵęčŧş şęľęčŧęđ: "
"selection-number": "Ńő. őƒ őþĵęčŧş şęľęčŧęđ: {{length}}"
}
},
"open": "Øpęʼn őpŧįőʼnş päʼnę",
"panels": {
"multi-select": {
"selection-number": "Ńő. őƒ päʼnęľş şęľęčŧęđ: "
"selection-number": "Ńő. őƒ päʼnęľş şęľęčŧęđ: {{length}}"
}
},
"row": {
"hide": "Ħįđę řőŵ ĥęäđęř",
"header": {
"hide": "Ħįđę",
"title": "Ŗőŵ ĥęäđęř"
},
"multi-select": {
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: "
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
}
}
},