Dashboards: Redesign tab edit pane and header (#101523)

* add tab item changes

* redesign tab edit pane and header actions

* Merge branch 'main' into serge/redesign-tab-edit-pane

* address comments

* adjust i18n typo

* sanitise url
This commit is contained in:
Sergej-Vlasov
2025-03-04 13:09:54 +00:00
committed by GitHub
parent 751ea772e6
commit 4f0e76ec56
10 changed files with 339 additions and 97 deletions
@@ -1,16 +1,15 @@
import { ReactNode } from 'react';
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 { getDefaultVizPanel } from '../../utils/utils';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
import { LayoutParent } from '../types/LayoutParent';
import { getEditOptions, renderActions } from './TabItemEditor';
import { getEditOptions } from './TabItemEditor';
import { TabItemRenderer } from './TabItemRenderer';
import { TabItems } from './TabItems';
import { TabsLayoutManager } from './TabsLayoutManager';
@@ -56,14 +55,6 @@ export class TabItem
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
public onDelete() {
const layout = sceneGraph.getAncestor(this, TabsLayoutManager);
layout.removeTab(this);
@@ -73,7 +64,43 @@ export class TabItem
return new TabItems(items.filter((item) => item instanceof TabItem));
}
public onAddPanel(panel = getDefaultVizPanel()) {
this.getLayout().addPanel(panel);
}
public onAddTabBefore() {
this._getParentLayout().addTabBefore(this);
}
public onAddTabAfter() {
this._getParentLayout().addTabAfter(this);
}
public onMoveLeft() {
this._getParentLayout().moveTabLeft(this);
}
public onMoveRight() {
this._getParentLayout().moveTabRight(this);
}
public isFirstTab(): boolean {
return this._getParentLayout().isFirstTab(this);
}
public isLastTab(): boolean {
return this._getParentLayout().isLastTab(this);
}
public onChangeTitle(title: string) {
this.setState({ title });
}
public getParentLayout(): TabsLayoutManager {
return this._getParentLayout();
}
private _getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
}
@@ -1,10 +1,11 @@
import { ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import { Button, Input } from '@grafana/ui';
import { Input } 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 { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { TabItem } from './TabItem';
@@ -12,9 +13,12 @@ import { TabItem } from './TabItem';
export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] {
const tabOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.tabs-layout.tab-options.title', 'Tab options'),
title: '',
id: 'tab-options',
isOpenDefault: true,
isOpenable: false,
renderTitle: () => (
<EditPaneHeader title={t('dashboard.tabs-layout.tab-options.title', 'Tab')} onDelete={() => model.onDelete()} />
),
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'),
@@ -29,15 +33,6 @@ export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[]
return [tabOptions, layoutOptions];
}
export function renderActions(tab: TabItem): ReactNode {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => tab.onDelete()} icon="trash-alt" />
</>
);
}
function TabTitleInput({ tab }: { tab: TabItem }) {
const { title } = tab.useState();
@@ -0,0 +1,90 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Dropdown, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { TabItem } from './TabItem';
interface Props {
model: TabItem;
}
export function TabItemMenu({ model }: Props) {
const styles = useStyles2(getStyles);
return (
<ToolbarButtonRow className={styles.container}>
<Dropdown
placement="bottom-end"
overlay={() => (
<Menu>
<Menu.Item
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-panel', 'Panel')}
label={t('dashboard.tabs-layout.tab.menu.add-panel', 'Panel')}
onClick={() => model.onAddPanel()}
/>
<Menu.Divider />
<Menu.Item
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-tab-before', 'Tab before')}
label={t('dashboard.tabs-layout.tab.menu.add-tab-above', 'Tab before')}
onClick={() => model.onAddTabBefore()}
/>
<Menu.Item
ariaLabel={t('dashboard.tabs-layout.tab.menu.add-tab-after', 'Tab after')}
label={t('dashboard.tabs-layout.tab.menu.add-tab-after', 'Tab after')}
onClick={() => model.onAddTabAfter()}
/>
</Menu>
)}
>
<ToolbarButton
aria-label={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
title={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
tooltip={t('dashboard.tabs-layout.tab.menu.add', 'Add tab')}
icon="plus"
iconSize="xs"
variant="default"
>
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
</ToolbarButton>
</Dropdown>
<Dropdown
placement="bottom-end"
overlay={() => (
<Menu>
<Menu.Item
aria-label={t('dashboard.tabs-layout.tab.menu.move-left', 'Move tab left')}
label={t('dashboard.tabs-layout.tab.menu.move-left', 'Move tab left')}
onClick={() => model.onMoveLeft()}
/>
<Menu.Divider />
<Menu.Item
aria-label={t('dashboard.tabs-layout.tab.menu.move-right', 'Move tab right')}
label={t('dashboard.tabs-layout.tab.menu.move-right', 'Move tab right')}
onClick={() => model.onMoveRight()}
/>
</Menu>
)}
>
<ToolbarButton
aria-label={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
title={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
tooltip={t('dashboard.tabs-layout.menu.move-tab', 'Move tab')}
icon="arrows-h"
iconSize="md"
variant="default"
/>
</Dropdown>
</ToolbarButtonRow>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
gap: theme.spacing(0),
flexShrink: 0,
}),
};
}
@@ -1,41 +1,98 @@
import { useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { useLocation } from 'react-router';
import { locationUtil } from '@grafana/data';
import { GrafanaTheme2, locationUtil, textUtil } from '@grafana/data';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Tab, useElementSelection } from '@grafana/ui';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { Checkbox, clearButtonStyles, useElementSelection, useStyles2 } from '@grafana/ui';
// eslint-disable-next-line no-restricted-imports
import { getFocusStyles } from '@grafana/ui/src/themes/mixins';
import { TabItem } from './TabItem';
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const { title, key } = model.useState();
const isClone = useMemo(() => isClonedKey(key!), [key]);
const parentLayout = model.getParentLayout();
const { tabs, currentTabIndex } = parentLayout.useState();
const dashboard = getDashboardSceneFor(model);
const { isEditing } = dashboard.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const { isSelected, onSelect } = useElementSelection(key);
const myIndex = tabs.findIndex((tab) => tab === model);
const isActive = myIndex === currentTabIndex;
const location = useLocation();
const href = locationUtil.getUrlForPartial(location, { tab: myIndex });
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { tab: myIndex }));
const styles = useStyles2(getStyles);
const clearStyles = useStyles2(clearButtonStyles);
return (
<Tab
className={!isClone && isSelected ? 'dashboard-selected-element' : undefined}
label={titleInterpolated}
active={isActive}
href={href}
onPointerDown={(evt) => {
if (isEditing && isActive && !isClone) {
evt.stopPropagation();
onSelect?.(evt);
}
}}
/>
<>
<div className={cx(styles.container, isSelected && 'dashboard-selected-element')} role="presentation">
<span onPointerDown={onSelect}>
<Checkbox value={!!isSelected} />
</span>
<a
href={href}
className={cx(clearStyles, styles.label, isActive ? styles.labelActive : styles.labelNotActive)}
role="tab"
aria-selected={isActive}
>
{titleInterpolated}
</a>
</div>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
listStyle: 'none',
position: 'relative',
display: 'flex',
whiteSpace: 'nowrap',
alignItems: 'center',
}),
label: css({
color: theme.colors.text.secondary,
padding: theme.spacing(1, 1.5, 0.5),
borderRadius: theme.shape.radius.default,
userSelect: 'none',
display: 'block',
height: '100%',
svg: {
marginRight: theme.spacing(1),
},
'&:focus-visible': getFocusStyles(theme),
'&::before': {
display: 'block',
content: '" "',
position: 'absolute',
left: 0,
right: 0,
height: '4px',
borderRadius: theme.shape.radius.default,
bottom: 0,
},
}),
labelNotActive: css({
'a:hover, &:hover, &:focus': {
color: theme.colors.text.primary,
'&::before': {
backgroundColor: theme.colors.action.hover,
},
},
}),
labelActive: css({
color: theme.colors.text.primary,
overflow: 'hidden',
'&::before': {
backgroundImage: theme.colors.gradients.brandHorizontal,
},
}),
};
}
@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { t } from 'app/core/internationalization';
@@ -8,7 +7,7 @@ import { EditableDashboardElementInfo } from '../types/EditableDashboardElement'
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
import { TabItem } from './TabItem';
import { getEditOptions, renderActions } from './TabItemsEditor';
import { getEditOptions } from './TabItemsEditor';
export class TabItems implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
@@ -26,10 +25,6 @@ export class TabItems implements MultiSelectedEditableDashboardElement {
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public getTabs(): TabItem[] {
return this._tabs;
}
@@ -1,36 +1,27 @@
import { useMemo } from 'react';
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 { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
import { TabItems } from './TabItems';
export function getEditOptions(_model: TabItems): OptionsPaneCategoryDescriptor[] {
export function getEditOptions(model: TabItems): OptionsPaneCategoryDescriptor[] {
const tabOptions = useMemo(() => {
const tabs = model.getTabs();
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.tab.multi-select.options-header', 'Multi-selected Tab options'),
title: ``,
id: 'ms-tab-options',
isOpenDefault: true,
isOpenable: false,
renderTitle: () => (
<EditPaneHeader
title={t('dashboard.tabs-layout.multi-select.title', '{{length}} tabs selected', { length: tabs.length })}
onDelete={() => model.onDelete()}
/>
),
});
}, []);
}, [model]);
return [tabOptions];
}
export function renderActions(model: TabItems) {
const tabs = model.getTabs();
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.tab.multi-select.selection-number">No. of tabs selected: </Trans>
{tabs.length}
</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>
);
}
@@ -128,6 +128,50 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
this.setState({ tabs, currentTabIndex: 0 });
}
public addTabBefore(tab: TabItem) {
const newTab = new TabItem();
const tabs = this.state.tabs.slice();
tabs.splice(tabs.indexOf(tab), 0, newTab);
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex });
}
public addTabAfter(tab: TabItem) {
const newTab = new TabItem();
const tabs = this.state.tabs.slice();
tabs.splice(tabs.indexOf(tab) + 1, 0, newTab);
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
}
public moveTabLeft(tab: TabItem) {
const currentIndex = this.state.tabs.indexOf(tab);
if (currentIndex <= 0) {
return;
}
const tabs = this.state.tabs.slice();
tabs.splice(currentIndex, 1);
tabs.splice(currentIndex - 1, 0, tab);
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex - 1 });
}
public moveTabRight(tab: TabItem) {
const currentIndex = this.state.tabs.indexOf(tab);
if (currentIndex >= this.state.tabs.length - 1) {
return;
}
const tabs = this.state.tabs.slice();
tabs.splice(currentIndex, 1);
tabs.splice(currentIndex + 1, 0, tab);
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
}
public isFirstTab(tab: TabItem): boolean {
return this.state.tabs[0] === tab;
}
public isLastTab(tab: TabItem): boolean {
return this.state.tabs[this.state.tabs.length - 1] === tab;
}
public static createEmpty(): TabsLayoutManager {
const tab = new TabItem();
return new TabsLayoutManager({ tabs: [tab] });
@@ -1,9 +1,13 @@
import { css } from '@emotion/css';
import { Fragment } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { Divider, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { getDashboardSceneFor } from '../../utils/utils';
import { TabItemMenu } from './TabItemMenu';
import { TabsLayoutManager } from './TabsLayoutManager';
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
@@ -11,13 +15,23 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
const { tabs, currentTabIndex } = model.useState();
const currentTab = tabs[currentTabIndex];
const { layout } = currentTab.useState();
const dashboard = getDashboardSceneFor(model);
const { isEditing } = dashboard.useState();
return (
<>
<TabsBar className={styles.tabsContainer}>
{tabs.map((tab) => (
<tab.Component model={tab} key={tab.state.key!} />
))}
<TabsBar className={styles.tabsWrapper}>
<div className={styles.tabsRow}>
<div className={styles.tabsContainer}>
{tabs.map((tab, idx) => (
<Fragment key={tab.state.key!}>
{isEditing && idx > 0 && <Divider direction="vertical" />}
<tab.Component model={tab} />
</Fragment>
))}
</div>
{isEditing && <TabItemMenu model={currentTab} />}
</div>
</TabsBar>
<TabContent className={styles.tabContentContainer}>
{currentTab && <layout.Component model={layout} />}
@@ -27,10 +41,21 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
}
const getStyles = (theme: GrafanaTheme2) => ({
tabsWrapper: css({
overflow: 'hidden',
}),
tabsRow: css({
justifyContent: 'space-between',
display: 'flex',
width: '100%',
}),
tabsContainer: css({
flexShrink: 1,
padding: '2px 2px 0 2px',
marginBottom: theme.spacing(1),
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
overflowX: 'scroll',
overflowY: 'visible',
paddingInline: theme.spacing(0.125),
}),
tabContentContainer: css({
backgroundColor: 'transparent',
+16 -7
View File
@@ -1087,12 +1087,6 @@
"options-header": "Multi-selected Row options",
"selection-number": "No. of rows selected: {{length}}"
}
},
"tab": {
"multi-select": {
"options-header": "Multi-selected Tab options",
"selection-number": "No. of tabs selected: "
}
}
},
"editpane": {
@@ -1250,12 +1244,27 @@
},
"tabs-layout": {
"description": "Tabs layout",
"menu": {
"move-tab": "Move tab"
},
"multi-select": {
"title": "{{length}} tabs selected"
},
"name": "Tabs",
"tab": {
"menu": {
"add": "Add tab",
"add-panel": "Panel",
"add-tab-above": "Tab before",
"add-tab-after": "Tab after",
"add-tab-before": "Tab before",
"move-left": "Move tab left",
"move-right": "Move tab right"
},
"new": "New tab"
},
"tab-options": {
"title": "Tab options",
"title": "Tab",
"title-option": "Title"
}
},
+16 -7
View File
@@ -1087,12 +1087,6 @@
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
}
},
"tab": {
"multi-select": {
"options-header": "Mūľŧį-şęľęčŧęđ Ŧäþ őpŧįőʼnş",
"selection-number": "Ńő. őƒ ŧäþş şęľęčŧęđ: "
}
}
},
"editpane": {
@@ -1250,12 +1244,27 @@
},
"tabs-layout": {
"description": "Ŧäþş ľäyőūŧ",
"menu": {
"move-tab": "Mővę ŧäþ"
},
"multi-select": {
"title": "{{length}} ŧäþş şęľęčŧęđ"
},
"name": "Ŧäþş",
"tab": {
"menu": {
"add": "Åđđ ŧäþ",
"add-panel": "Päʼnęľ",
"add-tab-above": "Ŧäþ þęƒőřę",
"add-tab-after": "Ŧäþ äƒŧęř",
"add-tab-before": "Ŧäþ þęƒőřę",
"move-left": "Mővę ŧäþ ľęƒŧ",
"move-right": "Mővę ŧäþ řįģĥŧ"
},
"new": "Ńęŵ ŧäþ"
},
"tab-options": {
"title": "Ŧäþ őpŧįőʼnş",
"title": "Ŧäþ",
"title-option": "Ŧįŧľę"
}
},