mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboards: Finalize refactoring for dynamic dashboards (#100198)
This commit is contained in:
parent
4b9fee61a8
commit
a51e785bc1
@ -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!)) {
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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],
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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()} />;
|
||||
}
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
@ -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'),
|
||||
|
@ -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],
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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%',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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%',
|
||||
}),
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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,
|
||||
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user