Dashboards: Redesign edit pane of dashboard and viz panels (#101437)

* adjust options pane category component and shared logic

* redesign dashboard edit pane

* clean up

* redesign viz panel edit panes

* address comments

* updated i18n

* clean up

* address comments
This commit is contained in:
Sergej-Vlasov
2025-03-03 10:49:32 +00:00
committed by GitHub
parent 070d4b2ee4
commit d3b550c230
12 changed files with 252 additions and 115 deletions
@@ -6,7 +6,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { DashboardLayoutSelector } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
export class DashboardEditableElement implements EditableDashboardElement {
@@ -28,29 +28,29 @@ export class DashboardEditableElement implements EditableDashboardElement {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.options.title', 'Dashboard options'),
id: 'dashboard-options',
isOpenDefault: true,
isOpenable: false,
})
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.options.title-option', 'Title'),
render: function renderTitle() {
return <DashboardTitleInput dashboard={dashboard} />;
},
render: () => <DashboardTitleInput dashboard={dashboard} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.options.description', 'Description'),
render: function renderTitle() {
return <DashboardDescriptionInput dashboard={dashboard} />;
},
render: () => <DashboardDescriptionInput dashboard={dashboard} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.layout.common.layout', 'Layout'),
render: () => <DashboardLayoutSelector layoutManager={body} />,
})
);
}, [dashboard]);
}, [body, dashboard]);
const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
return [dashboardOptions];
}
}
@@ -0,0 +1,57 @@
import { Dropdown, Button, IconButton, Menu, Stack, Icon } from '@grafana/ui';
import { t } from 'app/core/internationalization';
interface EditPaneHeaderProps {
title: string;
onDelete?: () => void;
onCopy?: () => void;
onDuplicate?: () => void;
}
export const EditPaneHeader = ({ title, onDelete, onCopy, onDuplicate }: EditPaneHeaderProps) => {
const addCopyOrDuplicate = onCopy || onDuplicate;
return (
<Stack justifyContent="space-between" alignItems="center" width="100%">
<span>{title}</span>
<Stack alignItems="center">
{addCopyOrDuplicate ? (
<Dropdown overlay={<MenuItems onCopy={onCopy} onDuplicate={onDuplicate} />}>
<Button
tooltip={t('dashboard.layout.common.copy-or-duplicate', 'Copy or Duplicate')}
tooltipPlacement="bottom"
variant="secondary"
fill="text"
size="md"
>
<Icon name="copy" /> <Icon name="angle-down" />
</Button>
</Dropdown>
) : null}
<IconButton
size="md"
variant="secondary"
onClick={onDelete}
name="trash-alt"
tooltip={t('dashboard.layout.common.delete', 'Delete')}
/>
</Stack>
</Stack>
);
};
type MenuItemsProps = {
onCopy?: () => void;
onDuplicate?: () => void;
};
const MenuItems = ({ onCopy, onDuplicate }: MenuItemsProps) => {
return (
<Menu>
{onCopy ? <Menu.Item label={t('dashboard.layout.common.copy', 'Copy')} onClick={onCopy} /> : null}
{onDuplicate ? (
<Menu.Item label={t('dashboard.layout.common.duplicate', 'Duplicate')} onClick={onDuplicate} />
) : null}
</Menu>
);
};
@@ -24,6 +24,8 @@ export function ElementEditPane({ element }: Props) {
title={elementInfo.name}
isOpenDefault={true}
className={styles.noBorderTop}
renderTitle={element.renderTitle}
isOpenable={element.isOpenable}
>
<div className={styles.actionsBox}>{element.renderActions()}</div>
</OptionsPaneCategory>
@@ -1,14 +1,15 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { VizPanel } from '@grafana/scenes';
import { Button, Stack, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { EditPaneHeader } from './EditPaneHeader';
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly key: string;
@@ -21,23 +22,22 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita
return { name: t('dashboard.edit-pane.elements.panels', 'Panels'), typeId: 'panels', icon: 'folder' };
}
renderActions(): ReactNode {
return (
<Stack direction="column">
<Text>
<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" />
</Stack>
</Stack>
);
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const header = new OptionsPaneCategoryDescriptor({
title: ``,
id: 'panel-header',
isOpenable: false,
renderTitle: () => (
<EditPaneHeader
title={t('dashboard.layout.common.panels-title', '{{length}} Panels Selected', {
length: this._panels.length,
})}
onDelete={() => this.onDelete()}
/>
),
});
return [header];
}
public onDelete() {
@@ -1,11 +1,12 @@
import { ReactNode, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import { sceneGraph, VizPanel } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { useStyles2, Text, Icon, Stack, Tooltip } 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 { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import {
PanelBackgroundSwitch,
@@ -16,11 +17,16 @@ import { BulkActionElement } from '../scene/types/BulkActionElement';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getEditPanelUrl } from '../utils/urlBuilders';
import { getPanelIdForVizPanel } from '../utils/utils';
import { EditPaneHeader } from './EditPaneHeader';
export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement {
public readonly isEditableDashboardElement = true;
public readonly typeName = 'Panel';
public constructor(private panel: VizPanel) {}
public constructor(public panel: VizPanel) {}
public getEditableElementInfo(): EditableDashboardElementInfo {
return {
@@ -36,35 +42,38 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
const panelOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.viz-panel.options.title', 'Panel options'),
id: 'panel-options',
isOpenDefault: true,
title: ``,
id: 'panel-header',
isOpenable: false,
renderTitle: () => (
<EditPaneHeader title={t('dashboard.viz-panel.options.title', 'Panel')} onDelete={() => this.onDelete()} />
),
})
.addItem(
new OptionsPaneItemDescriptor({
title: '',
render: () => <OpenPanelEditViz panel={this.panel} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.viz-panel.options.title-option', 'Title'),
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return <PanelFrameTitleInput panel={panel} />;
},
render: () => <PanelFrameTitleInput panel={panel} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.viz-panel.options.description', 'Description'),
value: panel.state.description,
render: function renderDescription() {
return <PanelDescriptionTextArea panel={panel} />;
},
render: () => <PanelDescriptionTextArea panel={panel} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.viz-panel.options.transparent-background', 'Transparent background'),
render: function renderTransparent() {
return <PanelBackgroundSwitch panel={panel} />;
},
render: () => <PanelBackgroundSwitch panel={panel} />,
})
);
}, [panel]);
@@ -76,50 +85,73 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
return undefined;
}, [layoutElement]);
const { options, fieldConfig, _pluginInstanceState } = panel.useState();
const dataProvider = sceneGraph.getData(panel);
const { data } = dataProvider.useState();
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();
if (!plugin) {
return [];
}
return getVisualizationOptions2({
panel,
data,
plugin: plugin,
eventBus: panel.getPanelContext().eventBus,
instanceState: _pluginInstanceState,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, panel, options, fieldConfig, _pluginInstanceState]);
const categories = [panelOptions];
if (layoutCategory) {
categories.push(layoutCategory);
}
categories.push(...visualizationOptions);
return categories;
}
public onDelete = () => {
public onDelete() {
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
layout.removePanel?.(this.panel);
};
public renderActions(): ReactNode {
return (
<>
<Button size="sm" variant="secondary">
<Trans i18nKey="panel.header-menu.edit">Edit</Trans>
</Button>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
</>
);
}
}
type OpenPanelEditVizProps = {
panel: VizPanel;
};
const OpenPanelEditViz = ({ panel }: OpenPanelEditVizProps) => {
const styles = useStyles2(getStyles);
const plugin = panel.getPlugin();
const imgSrc = plugin?.meta.info.logos.small;
return (
<Stack alignItems="center" width="100%">
{plugin ? (
<Tooltip content={t('dashboard.viz-panel.options.open-edit', 'Open Panel Edit')}>
<a
href={textUtil.sanitizeUrl(getEditPanelUrl(getPanelIdForVizPanel(panel)))}
className={cx(styles.pluginDescriptionWrapper)}
>
<img
className={styles.panelVizImg}
src={imgSrc}
alt={t('dashboard.viz-panel.options.plugin-type-image', 'Image of plugin type')}
/>
<Text truncate>{plugin.meta.name}</Text>
<Icon className={styles.panelVizIcon} name="sliders-v-alt" />
</a>
</Tooltip>
) : null}
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
pluginDescriptionWrapper: css({
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
columnGap: theme.spacing(1),
rowGap: theme.spacing(0.5),
minHeight: theme.spacing(4),
backgroundColor: theme.components.input.background,
border: `1px solid ${theme.colors.border.strong}`,
borderRadius: theme.shape.radius.default,
paddingInline: theme.spacing(1),
paddingBlock: theme.spacing(0.5),
flexGrow: 1,
}),
panelVizImg: css({
width: '16px',
height: '16px',
marginRight: theme.spacing(1),
}),
panelVizIcon: css({
marginLeft: 'auto',
}),
});
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { Select } 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';
@@ -55,10 +56,8 @@ export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
layoutCategory.addItem(
new OptionsPaneItemDescriptor({
title: 'Type',
render: function renderTitle() {
return <DashboardLayoutSelector layoutManager={layoutManager} />;
},
title: t('dashboard.layout.common.layout', 'Layout'),
render: () => <DashboardLayoutSelector layoutManager={layoutManager} />,
})
);
@@ -32,6 +32,16 @@ export interface EditableDashboardElement {
* creates a new multi-selection element from a list of selected items
*/
createMultiSelectedElement?(items: SceneObject[]): MultiSelectedEditableDashboardElement;
/**
* Return custom title for the edit panel header
*/
renderTitle?(): ReactNode;
/**
* determines if first edit panel header can be collapsed
*/
isOpenable?: Readonly<boolean>;
}
export interface EditableDashboardElementInfo {
@@ -27,6 +27,16 @@ export interface MultiSelectedEditableDashboardElement {
* Panel Actions
**/
renderActions?(): ReactNode;
/**
* Return custom title for the edit panel header
*/
renderTitle?(): ReactNode;
/**
* determines if first edit panel header can be collapsed
*/
isOpenable?: Readonly<boolean>;
}
export function isMultiSelectedEditableDashboardElement(obj: object): obj is MultiSelectedEditableDashboardElement {
@@ -21,6 +21,7 @@ export interface OptionsPaneCategoryProps {
isNested?: boolean;
children: ReactNode;
sandboxId?: string;
isOpenable?: boolean;
}
const CATEGORY_PARAM_NAME = 'showCategory' as const;
@@ -37,12 +38,13 @@ export const OptionsPaneCategory = React.memo(
itemsCount,
isNested = false,
sandboxId,
isOpenable = true,
}: OptionsPaneCategoryProps) => {
const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), {
isExpanded: isOpenDefault,
});
const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? isOpenDefault);
const [isExpanded, setIsExpanded] = useState(!isOpenable || (savedState?.isExpanded ?? isOpenDefault));
const manualClickTime = useRef(0);
const ref = useRef<HTMLDivElement>(null);
const [queryParams, updateQueryParams] = useQueryParams();
@@ -66,6 +68,9 @@ export const OptionsPaneCategory = React.memo(
}, [forceOpen, isExpanded, isOpenFromUrl]);
const onToggle = useCallback(() => {
if (!isOpenable) {
return;
}
manualClickTime.current = Date.now();
updateQueryParams(
{
@@ -75,7 +80,7 @@ export const OptionsPaneCategory = React.memo(
);
setSavedState({ isExpanded: !isExpanded });
setIsExpanded(!isExpanded);
}, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]);
}, [isOpenable, updateQueryParams, isExpanded, id, setSavedState]);
if (!renderTitle) {
renderTitle = function defaultTitle(isExpanded: boolean) {
@@ -101,6 +106,7 @@ export const OptionsPaneCategory = React.memo(
);
const headerStyles = cx(styles.header, {
[styles.headerHover]: isOpenable,
[styles.headerExpanded]: isExpanded,
[styles.headerNested]: isNested,
});
@@ -123,17 +129,19 @@ export const OptionsPaneCategory = React.memo(
<h6 id={`button-${id}`} className={styles.title}>
{renderTitle(isExpanded)}
</h6>
<Button
data-testid={selectors.components.OptionsGroup.toggle(id)}
type="button"
fill="text"
size="md"
variant="secondary"
aria-expanded={isExpanded}
className={styles.toggleButton}
icon={isExpanded ? 'angle-up' : 'angle-down'}
onClick={onToggle}
/>
{isOpenable ? (
<Button
data-testid={selectors.components.OptionsGroup.toggle(id)}
type="button"
fill="text"
size="md"
variant="secondary"
aria-expanded={isExpanded}
className={styles.toggleButton}
icon={isExpanded ? 'angle-up' : 'angle-down'}
onClick={onToggle}
/>
) : null}
</div>
{isExpanded && (
<div className={bodyStyles} id={id} aria-labelledby={`button-${id}`}>
@@ -161,15 +169,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
fontSize: '1rem',
fontWeight: theme.typography.fontWeightMedium,
margin: 0,
height: theme.spacing(4),
display: 'flex',
alignItems: 'center',
}),
header: css({
display: 'flex',
cursor: 'pointer',
alignItems: 'center',
padding: theme.spacing(0.5, 1.5),
color: theme.colors.text.primary,
fontWeight: theme.typography.fontWeightMedium,
}),
headerHover: css({
cursor: 'pointer',
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
},
@@ -16,6 +16,7 @@ export interface OptionsPaneCategoryDescriptorProps {
itemsCount?: number;
customRender?: () => React.ReactNode;
sandboxId?: string;
isOpenable?: boolean;
}
/**
+13 -6
View File
@@ -1078,11 +1078,6 @@
}
},
"open": "Open options pane",
"panels": {
"multi-select": {
"selection-number": "No. of panels selected: {{length}}"
}
},
"row": {
"header": {
"hide": "Hide",
@@ -1171,6 +1166,16 @@
"rows": "Total number rows",
"table-title": "Stats"
},
"layout": {
"common": {
"copy": "Copy",
"copy-or-duplicate": "Copy or Duplicate",
"delete": "Delete",
"duplicate": "Duplicate",
"layout": "Layout",
"panels-title": "{{length}} Panels Selected"
}
},
"options": {
"description": "Description",
"title": "Dashboard options",
@@ -1325,7 +1330,9 @@
"viz-panel": {
"options": {
"description": "Description",
"title": "Panel options",
"open-edit": "Open Panel Edit",
"plugin-type-image": "Image of plugin type",
"title": "Panel",
"title-option": "Title",
"transparent-background": "Transparent background"
}
+13 -6
View File
@@ -1078,11 +1078,6 @@
}
},
"open": "Øpęʼn őpŧįőʼnş päʼnę",
"panels": {
"multi-select": {
"selection-number": "Ńő. őƒ päʼnęľş şęľęčŧęđ: {{length}}"
}
},
"row": {
"header": {
"hide": "Ħįđę",
@@ -1171,6 +1166,16 @@
"rows": "Ŧőŧäľ ʼnūmþęř řőŵş",
"table-title": "Ŝŧäŧş"
},
"layout": {
"common": {
"copy": "Cőpy",
"copy-or-duplicate": "Cőpy őř Đūpľįčäŧę",
"delete": "Đęľęŧę",
"duplicate": "Đūpľįčäŧę",
"layout": "Ŀäyőūŧ",
"panels-title": "{{length}} Päʼnęľş Ŝęľęčŧęđ"
}
},
"options": {
"description": "Đęşčřįpŧįőʼn",
"title": "Đäşĥþőäřđ őpŧįőʼnş",
@@ -1325,7 +1330,9 @@
"viz-panel": {
"options": {
"description": "Đęşčřįpŧįőʼn",
"title": "Päʼnęľ őpŧįőʼnş",
"open-edit": "Øpęʼn Päʼnęľ Ēđįŧ",
"plugin-type-image": "Ĩmäģę őƒ pľūģįʼn ŧypę",
"title": "Päʼnęľ",
"title-option": "Ŧįŧľę",
"transparent-background": "Ŧřäʼnşpäřęʼnŧ þäčĸģřőūʼnđ"
}