Dashboards: Add tabs layout (#100127)

This commit is contained in:
Bogdan Matei
2025-02-07 16:50:53 +02:00
committed by GitHub
parent f607bcd43f
commit be42685852
17 changed files with 540 additions and 8 deletions
@@ -53,7 +53,7 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
if (href) {
return (
<div className={tabsStyles.item}>
<div className={cx(tabsStyles.item, className)}>
<a
{...commonProps}
href={href}
@@ -68,7 +68,7 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
}
return (
<div className={tabsStyles.item}>
<div className={cx(tabsStyles.item, className)}>
<button
{...commonProps}
type="button"
@@ -595,6 +595,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.state.body.addNewRow();
}
public onCreateNewTab() {
this.state.body.addNewTab();
}
public onCreateNewPanel(): VizPanel {
const vizPanel = getDefaultVizPanel();
@@ -194,6 +194,25 @@ export function ToolbarActions({ dashboard }: Props) {
</Button>
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-tab-button"
variant="secondary"
size="sm"
icon="plus"
fill="text"
onClick={() => {
dashboard.onCreateNewTab();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_tab')}
>
<Trans i18nKey="dashboard.toolbar.add-tab">Tab</Trans>
</Button>
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
@@ -26,6 +26,7 @@ import {
getGridItemKeyForPanelId,
getDashboardSceneFor,
} from '../../utils/utils';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { DashboardGridItem } from './DashboardGridItem';
@@ -177,6 +178,22 @@ export class DefaultGridLayoutManager
return panels;
}
public hasVizPanels(): boolean {
for (const child of this.state.grid.state.children) {
if (child instanceof DashboardGridItem) {
return true;
} else if (child instanceof SceneGridRow) {
for (const rowChild of child.state.children) {
if (rowChild instanceof DashboardGridItem) {
return true;
}
}
}
}
return false;
}
public addNewRow(): SceneGridRow {
const id = dashboardSceneGraph.getNextPanelId(this);
@@ -205,6 +222,17 @@ export class DefaultGridLayoutManager
return row;
}
public addNewTab() {
const shouldAddTab = this.hasVizPanels();
const tabsLayout = TabsLayoutManager.createFromLayout(this);
if (shouldAddTab) {
tabsLayout.addNewTab();
}
getDashboardSceneFor(this).switchLayout(tabsLayout);
}
public editModeChanged(isEditing: boolean) {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
@@ -5,6 +5,7 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getDashboardSceneFor, getGridItemKeyForPanelId, getVizPanelKeyForPanelId } from '../../utils/utils';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { ResponsiveGridItem } from './ResponsiveGridItem';
@@ -101,12 +102,38 @@ export class ResponsiveGridLayoutManager
return panels;
}
public hasVizPanels(): boolean {
for (const child of this.state.layout.state.children) {
if (child instanceof ResponsiveGridItem) {
return true;
}
}
return false;
}
public addNewRow() {
const shouldAddRow = this.hasVizPanels();
const rowsLayout = RowsLayoutManager.createFromLayout(this);
rowsLayout.addNewRow();
if (shouldAddRow) {
rowsLayout.addNewRow();
}
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public addNewTab() {
const shouldAddTab = this.hasVizPanels();
const tabsLayout = TabsLayoutManager.createFromLayout(this);
if (shouldAddTab) {
tabsLayout.addNewTab();
}
getDashboardSceneFor(this).switchLayout(tabsLayout);
}
public getOptions(): OptionsPaneItemDescriptor[] {
return getEditOptions(this);
}
@@ -3,9 +3,11 @@ import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { RowItem } from './RowItem';
@@ -62,10 +64,31 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return panels;
}
public hasVizPanels(): boolean {
for (const row of this.state.rows) {
if (row.getLayout().hasVizPanels()) {
return true;
}
}
return false;
}
public addNewRow() {
this.setState({ rows: [...this.state.rows, new RowItem()] });
}
public addNewTab() {
const shouldAddTab = this.hasVizPanels();
const tabsLayout = TabsLayoutManager.createFromLayout(this);
if (shouldAddTab) {
tabsLayout.addNewTab();
}
getDashboardSceneFor(this).switchLayout(tabsLayout);
}
public editModeChanged(isEditing: boolean) {
this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing));
}
@@ -87,9 +110,8 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
public removeRow(row: RowItem) {
this.setState({
rows: this.state.rows.filter((r) => r !== row),
});
const rows = this.state.rows.filter((r) => r !== row);
this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows });
}
public static createEmpty(): RowsLayoutManager {
@@ -0,0 +1,80 @@
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 { 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 { getEditOptions, renderActions } from './TabItemEditor';
import { TabItemRenderer } from './TabItemRenderer';
import { TabItems } from './TabItems';
import { TabsLayoutManager } from './TabsLayoutManager';
export interface TabItemState extends SceneObjectState {
layout: DashboardLayoutManager;
title?: string;
}
export class TabItem
extends SceneObjectBase<TabItemState>
implements LayoutParent, BulkActionElement, EditableDashboardElement
{
public static Component = TabItemRenderer;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['title'],
});
public readonly isEditableDashboardElement = true;
public readonly typeName = 'Tab';
constructor(state?: Partial<TabItemState>) {
super({
...state,
title: state?.title ?? t('dashboard.tabs-layout.tab.new', 'New tab'),
layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(),
});
}
public getLayout(): DashboardLayoutManager {
return this.state.layout;
}
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout });
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
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);
}
public createMultiSelectedElement(items: SceneObject[]): TabItems {
return new TabItems(items.filter((item) => item instanceof TabItem));
}
public onChangeTab() {
this.getParentLayout().changeTab(this);
}
public onChangeTitle(title: string) {
this.setState({ title });
}
}
@@ -0,0 +1,45 @@
import { ReactNode, useMemo } from 'react';
import { Button, 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 { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
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'),
id: 'tab-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'),
render: () => <TabTitleInput tab={model} />,
})
);
}, [model]);
const { layout } = model.useState();
const layoutOptions = useLayoutCategory(layout);
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();
return <Input value={title} onChange={(e) => tab.onChangeTitle(e.currentTarget.value)} />;
}
@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Tab, useElementSelection } from '@grafana/ui';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
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 { currentTab } = parentLayout.useState();
const dashboard = getDashboardSceneFor(model);
const { isEditing } = dashboard.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const { isSelected, onSelect } = useElementSelection(key);
return (
<Tab
className={!isClone && isSelected ? 'dashboard-selected-element' : undefined}
label={titleInterpolated}
active={model === currentTab}
onPointerDown={(evt) => {
evt.stopPropagation();
if (isEditing) {
if (isClone) {
dashboard.state.editPane.clearSelection();
} else {
onSelect?.(evt);
}
}
parentLayout.changeTab(model);
}}
/>
);
}
@@ -0,0 +1,35 @@
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 { TabItem } from './TabItem';
import { getEditOptions, renderActions } from './TabItemsEditor';
export class TabItems implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Tabs';
public readonly key: string;
public constructor(private _tabs: TabItem[]) {
this.key = uuidv4();
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public getTabs(): TabItem[] {
return this._tabs;
}
public onDelete() {
this._tabs.forEach((tab) => tab.onDelete());
}
}
@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { Button, Stack, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { TabItems } from './TabItems';
export function getEditOptions(_model: TabItems): OptionsPaneCategoryDescriptor[] {
const tabOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.tab.multi-select.options-header', 'Multi-selected Tab options'),
id: 'ms-tab-options',
isOpenDefault: true,
});
}, []);
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>
);
}
@@ -0,0 +1,102 @@
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { TabItem } from './TabItem';
import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer';
interface TabsLayoutManagerState extends SceneObjectState {
tabs: TabItem[];
currentTab: TabItem;
}
export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> implements DashboardLayoutManager {
public static Component = TabsLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public static readonly descriptor = {
get name() {
return t('dashboard.tabs-layout.name', 'Tabs');
},
get description() {
return t('dashboard.tabs-layout.description', 'Tabs layout');
},
id: 'tabs-layout',
createFromLayout: TabsLayoutManager.createFromLayout,
};
public readonly descriptor = TabsLayoutManager.descriptor;
public addPanel(vizPanel: VizPanel) {
this.state.currentTab.getLayout().addPanel(vizPanel);
}
public getVizPanels(): VizPanel[] {
const panels: VizPanel[] = [];
for (const tab of this.state.tabs) {
const innerPanels = tab.getLayout().getVizPanels();
panels.push(...innerPanels);
}
return panels;
}
public hasVizPanels(): boolean {
for (const tab of this.state.tabs) {
if (tab.getLayout().hasVizPanels()) {
return true;
}
}
return false;
}
public addNewRow() {
this.state.currentTab.getLayout().addNewRow();
}
public addNewTab() {
const currentTab = new TabItem();
this.setState({ tabs: [...this.state.tabs, currentTab], currentTab });
}
public editModeChanged(isEditing: boolean) {
this.state.tabs.forEach((tab) => tab.getLayout().editModeChanged?.(isEditing));
}
public activateRepeaters() {
this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.());
}
public removeTab(tab: TabItem) {
if (this.state.currentTab === tab) {
const currentTabIndex = this.state.tabs.indexOf(tab);
const nextTabIndex = currentTabIndex === 0 ? 1 : currentTabIndex - 1;
const nextTab = this.state.tabs[nextTabIndex];
this.setState({ tabs: this.state.tabs.filter((t) => t !== tab), currentTab: nextTab });
return;
}
const filteredTab = this.state.tabs.filter((tab) => tab !== this.state.currentTab);
const tabs = filteredTab.length === 0 ? [new TabItem()] : filteredTab;
this.setState({ tabs, currentTab: tabs[tabs.length - 1] });
}
public changeTab(tab: TabItem) {
this.setState({ currentTab: tab });
}
public static createEmpty(): TabsLayoutManager {
const tab = new TabItem();
return new TabsLayoutManager({ tabs: [tab], currentTab: tab });
}
public static createFromLayout(layout: DashboardLayoutManager): TabsLayoutManager {
const tab = new TabItem({ layout: layout.clone() });
return new TabsLayoutManager({ tabs: [tab], currentTab: tab });
}
}
@@ -0,0 +1,41 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { TabsLayoutManager } from './TabsLayoutManager';
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
const styles = useStyles2(getStyles);
const { tabs, currentTab } = model.useState();
const { layout } = currentTab.useState();
return (
<>
<TabsBar className={styles.tabsContainer}>
{tabs.map((tab) => (
<tab.Component model={tab} key={tab.state.key!} />
))}
</TabsBar>
<TabContent className={styles.tabContentContainer}>{layout && <layout.Component model={layout} />}</TabContent>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
tabsContainer: css({
flexShrink: 1,
padding: '2px 2px 0 2px',
marginBottom: theme.spacing(1),
}),
tabContentContainer: css({
backgroundColor: 'transparent',
display: 'flex',
flex: 1,
height: '100%',
overflow: 'auto',
scrollbarWidth: 'thin',
padding: '2px 2px 0 2px',
}),
});
@@ -3,8 +3,14 @@ import { Registry } from '@grafana/data';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
export const layoutRegistry: Registry<LayoutRegistryItem> = new Registry<LayoutRegistryItem>(() => {
return [DefaultGridLayoutManager.descriptor, ResponsiveGridLayoutManager.descriptor, RowsLayoutManager.descriptor];
return [
DefaultGridLayoutManager.descriptor,
ResponsiveGridLayoutManager.descriptor,
RowsLayoutManager.descriptor,
TabsLayoutManager.descriptor,
];
});
@@ -34,15 +34,25 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
duplicatePanel?(panel: VizPanel): void;
/**
* getVizPanels
* Gets all the viz panels in the layout
*/
getVizPanels(): VizPanel[];
/**
* Check if the layout has viz panels
*/
hasVizPanels(): boolean;
/**
* Add row
*/
addNewRow(): void;
/**
* Add tab
*/
addNewTab(): void;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
+18
View File
@@ -969,6 +969,12 @@
"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: "
}
}
},
"empty": {
@@ -1094,11 +1100,23 @@
"title-option": "Title"
}
},
"tabs-layout": {
"description": "Tabs layout",
"name": "Tabs",
"tab": {
"new": "New tab"
},
"tab-options": {
"title": "Tab options",
"title-option": "Title"
}
},
"toolbar": {
"add": "Add",
"add-panel": "Panel",
"add-panel-lib": "Import",
"add-row": "Row",
"add-tab": "Tab",
"alert-rules": "Alert rules",
"back-to-dashboard": "Back to dashboard",
"dashboard-settings": {
+18
View File
@@ -969,6 +969,12 @@
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
}
},
"tab": {
"multi-select": {
"options-header": "Mūľŧį-şęľęčŧęđ Ŧäþ őpŧįőʼnş",
"selection-number": "Ńő. őƒ ŧäþş şęľęčŧęđ: "
}
}
},
"empty": {
@@ -1094,11 +1100,23 @@
"title-option": "Ŧįŧľę"
}
},
"tabs-layout": {
"description": "Ŧäþş ľäyőūŧ",
"name": "Ŧäþş",
"tab": {
"new": "Ńęŵ ŧäþ"
},
"tab-options": {
"title": "Ŧäþ őpŧįőʼnş",
"title-option": "Ŧįŧľę"
}
},
"toolbar": {
"add": "Åđđ",
"add-panel": "Päʼnęľ",
"add-panel-lib": "Ĩmpőřŧ",
"add-row": "Ŗőŵ",
"add-tab": "Ŧäþ",
"alert-rules": "Åľęřŧ řūľęş",
"back-to-dashboard": "ßäčĸ ŧő đäşĥþőäřđ",
"dashboard-settings": {