Dashboard: Panel edit and support for different layout items (#96203)

* Dashboard: Panel edit and support for more layout items

* It's working

* Fix discard issue

* remove unused file

* Update

* Editing for responsive grid items now work

* Update

* Update

* Review fix
This commit is contained in:
Torkel Ödegaard 2024-11-20 14:09:56 +01:00 committed by GitHub
parent 0cd19d76ce
commit b0fe898fa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 251 additions and 186 deletions

View File

@ -267,6 +267,8 @@ describe('PanelEditor', () => {
// Just adding an extra stateless behavior to verify unlinking does not remvoe it
const otherBehavior = jest.fn();
const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] });
new DashboardGridItem({ body: panel });
const editScene = buildPanelEditScene(panel);
editScene.onConfirmUnlinkLibraryPanel();

View File

@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { getPanelChanges } from '../saving/getDashboardChanges';
import { DashboardGridItem, DashboardGridItemState } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import {
activateSceneObjectAndParentTree,
@ -54,14 +54,22 @@ export interface PanelEditorState extends SceneObjectState {
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
static Component = PanelEditorRenderer;
private _originalLayoutElementState!: DashboardGridItemState;
private _layoutElement!: DashboardGridItem;
private _layoutItemState?: SceneObjectState;
private _layoutItem: DashboardLayoutItem;
private _originalSaveModel!: Panel;
private _changesHaveBeenMade = false;
public constructor(state: PanelEditorState) {
super(state);
const panel = this.state.panelRef.resolve();
const layoutItem = panel.parent;
if (!layoutItem || !isDashboardLayoutItem(layoutItem)) {
throw new Error('Panel must have a parent of type DashboardLayoutItem');
}
this._layoutItem = layoutItem;
this.setOriginalState(this.state.panelRef);
this.addActivationHandler(this._activationHandler.bind(this));
}
@ -69,14 +77,12 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _activationHandler() {
const panel = this.state.panelRef.resolve();
const deactivateParents = activateSceneObjectAndParentTree(panel);
const layoutElement = panel.parent;
this.waitForPlugin();
return () => {
if (layoutElement instanceof DashboardGridItem) {
layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade);
}
this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade);
if (deactivateParents) {
deactivateParents();
}
@ -102,11 +108,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
const panel = panelRef.resolve();
this._originalSaveModel = vizPanelToPanel(panel);
if (panel.parent instanceof DashboardGridItem) {
this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state);
this._layoutElement = panel.parent;
}
this._layoutItemState = sceneUtils.cloneSceneObjectState(this._layoutItem.state);
}
/**
@ -134,9 +136,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}
};
this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange));
// Repeat options live on the layout element (DashboardGridItem)
this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange));
// Subscribe to state changes on the parent (layout item) so we do not miss state changes on the layout item
this._subs.add(this._layoutItem.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange));
}
public getPanel(): VizPanel {
@ -145,15 +146,12 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private gotPanelPlugin(plugin: PanelPlugin) {
const panel = this.getPanel();
const layoutElement = panel.parent;
// First time initialization
if (this.state.isInitializing) {
this.setOriginalState(this.state.panelRef);
if (layoutElement instanceof DashboardGridItem) {
layoutElement.editingStarted();
}
this._layoutItem.editingStarted?.();
this._setupChangeDetection();
this._updateDataPane(plugin);
@ -255,7 +253,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
getDashboardSceneFor(this).removePanel(panel);
} else {
// Revert any layout element changes
this._layoutElement.setState(this._originalLayoutElementState!);
this._layoutItem!.setState(this._layoutItemState!);
}
locationService.partial({ editPanel: null });

View File

@ -13,7 +13,7 @@ import {
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
import { getPanelFrameCategory2 } from './getPanelFrameOptions';
import { getPanelFrameOptions } from './getPanelFrameOptions';
interface Props {
panel: VizPanel;
@ -25,13 +25,7 @@ interface Props {
export const PanelOptions = React.memo<Props>(({ panel, searchQuery, listMode, data }) => {
const { options, fieldConfig, _pluginInstanceState } = panel.useState();
const layoutElement = panel.parent!;
const layoutElementState = layoutElement.useState();
const panelFrameOptions = useMemo(
() => getPanelFrameCategory2(panel, layoutElementState),
[panel, layoutElementState]
);
const panelFrameOptions = useMemo(() => getPanelFrameOptions(panel), [panel]);
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();

View File

@ -1,26 +1,21 @@
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneObjectState, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui';
import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
import { DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui';
import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton';
import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton';
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 { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { VizPanelLinks } from '../scene/PanelLinks';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { isDashboardLayoutItem } from '../scene/types';
import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
export function getPanelFrameCategory2(
panel: VizPanel,
layoutElementState: SceneObjectState
): OptionsPaneCategoryDescriptor {
export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescriptor {
const descriptor = new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'Panel options',
@ -30,7 +25,7 @@ export function getPanelFrameCategory2(
const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel);
const links = panelLinksObject?.state.rawLinks ?? [];
const dashboard = getDashboardSceneFor(panel);
const layoutElement = panel.parent;
const layoutElement = panel.parent!;
descriptor
.addItem(
@ -97,72 +92,8 @@ export function getPanelFrameCategory2(
)
);
if (layoutElement instanceof DashboardGridItem) {
const gridItem = layoutElement;
const category = new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
render: function renderRepeatOptions() {
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
sceneContext={panel}
repeat={gridItem.state.variableName}
onChange={(value?: string) => gridItem.setRepeatByVariable(value)}
/>
);
},
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
showIf: () => Boolean(gridItem.state.variableName),
render: function renderRepeatOptions() {
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
return (
<RadioButtonGroup
options={directionOptions}
value={gridItem.state.repeatDirection ?? 'h'}
onChange={(value) => gridItem.setState({ repeatDirection: value })}
/>
);
},
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'),
render: function renderOption() {
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
return (
<Select
options={maxPerRowOptions}
value={gridItem.state.maxPerRow ?? 4}
onChange={(value) => gridItem.setState({ maxPerRow: value.value })}
/>
);
},
})
);
descriptor.addCategory(category);
if (isDashboardLayoutItem(layoutElement) && layoutElement.getOptions) {
descriptor.addCategory(layoutElement.getOptions());
}
return descriptor;

View File

@ -22,10 +22,13 @@ import {
SceneVariableDependencyConfigLike,
} from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils';
import { repeatPanelMenuBehavior } from '../PanelMenuBehavior';
import { DashboardRepeatsProcessedEvent } from '../types';
import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types';
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
export interface DashboardGridItemState extends SceneGridItemStateLike {
body: VizPanel;
@ -38,9 +41,11 @@ export interface DashboardGridItemState extends SceneGridItemStateLike {
export type RepeatDirection = 'v' | 'h';
export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> implements SceneGridItemLike {
export class DashboardGridItem
extends SceneObjectBase<DashboardGridItemState>
implements SceneGridItemLike, DashboardLayoutItem
{
private _prevRepeatValues?: VariableValueSingle[];
protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this);
public constructor(state: DashboardGridItemState) {
@ -189,6 +194,18 @@ export class DashboardGridItem extends SceneObjectBase<DashboardGridItemState> i
this.setState(stateUpdate);
}
/**
* DashboardLayoutItem interface start
*/
public isDashboardLayoutItem: true = true;
/**
* Returns options for panel edit
*/
public getOptions(): OptionsPaneCategoryDescriptor {
return getDashboardGridItemOptions(this);
}
/**
* Logic to prep panel for panel edit
*/

View File

@ -0,0 +1,95 @@
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup, Select } from '@grafana/ui';
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 { DashboardGridItem } from './DashboardGridItem';
export function getDashboardGridItemOptions(gridItem: DashboardGridItem) {
const category = new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
render: () => <RepeatByOption gridItem={gridItem} />,
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
useShowIf: () => {
const { variableName } = gridItem.useState();
return Boolean(variableName);
},
render: () => <RepeatDirectionOption gridItem={gridItem} />,
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
useShowIf: () => {
const { variableName, repeatDirection } = gridItem.useState();
return Boolean(variableName) && repeatDirection === 'h';
},
render: () => <MaxPerRowOption gridItem={gridItem} />,
})
);
return category;
}
interface OptionComponentProps {
gridItem: DashboardGridItem;
}
function RepeatDirectionOption({ gridItem }: OptionComponentProps) {
const { repeatDirection } = gridItem.useState();
const directionOptions: Array<SelectableValue<'h' | 'v'>> = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
return (
<RadioButtonGroup
options={directionOptions}
value={repeatDirection ?? 'h'}
onChange={(value) => gridItem.setState({ repeatDirection: value })}
/>
);
}
function MaxPerRowOption({ gridItem }: OptionComponentProps) {
const { maxPerRow } = gridItem.useState();
const maxPerRowOptions = [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 })}
/>
);
}
function RepeatByOption({ gridItem }: OptionComponentProps) {
const { variableName } = gridItem.useState();
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
sceneContext={gridItem}
repeat={variableName}
onChange={(value?: string) => gridItem.setRepeatByVariable(value)}
/>
);
}

View File

@ -370,8 +370,8 @@ export class DefaultGridLayoutManager
return new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: children,
isDraggable: false,
isResizable: false,
isDraggable: true,
isResizable: true,
}),
});
}

View File

@ -1,13 +1,16 @@
import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes';
import { Switch } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardLayoutItem } from '../types';
export interface ResponsiveGridItemState extends SceneObjectState {
body: VizPanel;
hideWhenNoData?: boolean;
}
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> {
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem {
public constructor(state: ResponsiveGridItemState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
@ -17,8 +20,6 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
if (!this.state.hideWhenNoData) {
return;
}
// TODO add hide when no data logic (in a behavior probably)
}
public toggleHideWhenNoData() {
@ -28,20 +29,28 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
/**
* DashboardLayoutElement interface
*/
public isDashboardLayoutElement: true = true;
public isDashboardLayoutItem: true = true;
public getOptions?(): OptionsPaneItemDescriptor[] {
public getOptions?(): OptionsPaneCategoryDescriptor {
const model = this;
return [
const category = new OptionsPaneCategoryDescriptor({
title: 'Layout options',
id: 'layout-options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: 'Hide when no data',
render: function renderTransparent() {
const { hideWhenNoData } = model.state;
const { hideWhenNoData } = model.useState();
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => model.toggleHideWhenNoData()} />;
},
}),
];
})
);
return category;
}
public setBody(body: SceneObject): void {

View File

@ -1,6 +1,6 @@
import { BusEventWithPayload, RegistryItem } from '@grafana/data';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
/**
* A scene object that usually wraps an underlying layout
@ -94,17 +94,21 @@ export function isLayoutParent(obj: SceneObject): obj is LayoutParent {
*/
export interface DashboardLayoutItem extends SceneObject {
/**
* Marks this object as a layout element
* Marks this object as a layout item
*/
isDashboardLayoutItem: true;
/**
* Return layout elements options (like repeat, repeat direction, etc for the default DashboardGridItem)
* Return layout item options (like repeat, repeat direction, etc for the default DashboardGridItem)
*/
getOptions?(): OptionsPaneItemDescriptor[];
getOptions?(): OptionsPaneCategoryDescriptor;
/**
* Only implemented by elements that wrap VizPanels
* When going into panel edit
**/
editingStarted?(): void;
/**
* When coming out of panel edit
*/
getVizPanel?(): VizPanel;
editingCompleted?(withChanges: boolean): void;
}
export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem {

View File

@ -11,7 +11,7 @@ import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
import { OptionsPaneItemOverrides } from './OptionsPaneItemOverrides';
import { OptionPaneItemOverrideInfo } from './types';
export interface OptionsPaneItemProps {
export interface OptionsPaneItemInfo {
title: string;
value?: any;
description?: string;
@ -19,6 +19,8 @@ export interface OptionsPaneItemProps {
render: () => React.ReactElement;
skipField?: boolean;
showIf?: () => boolean;
/** Hook for controlling visibility */
useShowIf?: () => boolean;
overrides?: OptionPaneItemOverrideInfo[];
addon?: ReactNode;
}
@ -29,56 +31,36 @@ export interface OptionsPaneItemProps {
export class OptionsPaneItemDescriptor {
parent!: OptionsPaneCategoryDescriptor;
constructor(public props: OptionsPaneItemProps) {}
getLabel(searchQuery?: string): ReactNode {
const { title, description, overrides, addon } = this.props;
if (!searchQuery) {
// Do not render label for categories with only one child
if (this.parent.props.title === title && !overrides?.length) {
return null;
}
return <OptionPaneLabel title={title} description={description} overrides={overrides} addon={addon} />;
}
const categories: React.ReactNode[] = [];
if (this.parent.parent) {
categories.push(this.highlightWord(this.parent.parent.props.title, searchQuery));
}
if (this.parent.props.title !== title) {
categories.push(this.highlightWord(this.parent.props.title, searchQuery));
}
return (
<Label description={description && this.highlightWord(description, searchQuery)} category={categories}>
{this.highlightWord(title, searchQuery)}
{overrides && overrides.length > 0 && <OptionsPaneItemOverrides overrides={overrides} />}
</Label>
);
}
highlightWord(word: string, query: string) {
return (
<Highlighter textToHighlight={word} searchWords={[query]} highlightClassName={'search-fragment-highlight'} />
);
}
renderOverrides() {
const { overrides } = this.props;
if (!overrides || overrides.length === 0) {
return;
}
}
constructor(public props: OptionsPaneItemInfo) {}
render(searchQuery?: string) {
const { title, description, render, showIf, skipField } = this.props;
const key = `${this.parent.props.id} ${title}`;
return <OptionsPaneItem key={this.props.title} itemDescriptor={this} searchQuery={searchQuery} />;
}
if (showIf && !showIf()) {
useShowIf() {
if (this.props.useShowIf) {
return this.props.useShowIf();
}
if (this.props.showIf) {
return this.props.showIf();
}
return true;
}
}
interface OptionsPaneItemProps {
itemDescriptor: OptionsPaneItemDescriptor;
searchQuery?: string;
}
function OptionsPaneItem({ itemDescriptor, searchQuery }: OptionsPaneItemProps) {
const { title, description, render, skipField } = itemDescriptor.props;
const key = `${itemDescriptor.parent.props.id} ${title}`;
const showIf = itemDescriptor.useShowIf();
if (!showIf) {
return null;
}
@ -88,7 +70,7 @@ export class OptionsPaneItemDescriptor {
return (
<Field
label={this.getLabel(searchQuery)}
label={renderOptionLabel(itemDescriptor, searchQuery)}
description={description}
key={key}
aria-label={selectors.components.PanelEditor.OptionsPane.fieldLabel(key)}
@ -96,7 +78,40 @@ export class OptionsPaneItemDescriptor {
{render()}
</Field>
);
}
function renderOptionLabel(itemDescriptor: OptionsPaneItemDescriptor, searchQuery?: string): ReactNode {
const { title, description, overrides, addon } = itemDescriptor.props;
if (!searchQuery) {
// Do not render label for categories with only one child
if (itemDescriptor.parent.props.title === title && !overrides?.length) {
return null;
}
return <OptionPaneLabel title={title} description={description} overrides={overrides} addon={addon} />;
}
const categories: React.ReactNode[] = [];
if (itemDescriptor.parent.parent) {
categories.push(highlightWord(itemDescriptor.parent.parent.props.title, searchQuery));
}
if (itemDescriptor.parent.props.title !== title) {
categories.push(highlightWord(itemDescriptor.parent.props.title, searchQuery));
}
return (
<Label description={description && highlightWord(description, searchQuery)} category={categories}>
{highlightWord(title, searchQuery)}
{overrides && overrides.length > 0 && <OptionsPaneItemOverrides overrides={overrides} />}
</Label>
);
}
function highlightWord(word: string, query: string) {
return <Highlighter textToHighlight={word} searchWords={[query]} highlightClassName={'search-fragment-highlight'} />;
}
interface OptionPanelLabelProps {