diff --git a/dashboards/src/components/Dashboard/Dashboard.tsx b/dashboards/src/components/Dashboard/Dashboard.tsx
index f52502d5..f88b9c23 100644
--- a/dashboards/src/components/Dashboard/Dashboard.tsx
+++ b/dashboards/src/components/Dashboard/Dashboard.tsx
@@ -14,8 +14,9 @@
import { Box, BoxProps } from '@mui/material';
import { ErrorBoundary, ErrorAlert } from '@perses-dev/components';
import { ReactElement, useRef } from 'react';
-import { usePanelGroupIds } from '../../context';
-import { GridLayout } from '../GridLayout';
+import { usePanelGroup, usePanelGroupIds } from '../../context';
+import { GridLayout, GridLayoutProps } from '../GridLayout';
+import { TabLayout } from '../TabLayout';
import { EmptyDashboard, EmptyDashboardProps } from '../EmptyDashboard';
import { PanelOptions } from '../Panel';
@@ -30,6 +31,20 @@ export type DashboardProps = BoxProps & {
};
const HEADER_HEIGHT = 165; // Approximate height of the header in dashboard view (including the navbar and variables toolbar)
+function PanelGroupRenderer({ panelGroupId, panelOptions, panelFullHeight }: GridLayoutProps): ReactElement {
+ const group = usePanelGroup(panelGroupId);
+ switch (group.layoutKind) {
+ case 'Tabs':
+ return ;
+ case 'Grid':
+ return ;
+ default: {
+ const _exhaustiveCheck: never = group;
+ throw new Error(`Unknown layout kind: ${(_exhaustiveCheck as { layoutKind: string }).layoutKind}`);
+ }
+ }
+}
+
/**
* Renders a Dashboard for the provided Dashboard spec.
*/
@@ -50,7 +65,7 @@ export function Dashboard({ emptyDashboardProps, panelOptions, ...boxProps }: Da
)}
{!isEmpty &&
panelGroupIds.map((panelGroupId) => (
-
);
}
-
-const calculateGridItemWidth = (w: number, colWidth: number): number => {
- // 0 * Infinity === NaN, which causes problems with resize contraints
- if (!Number.isFinite(w)) return w;
- return Math.round(colWidth * w + Math.max(0, w - 1) * DEFAULT_MARGIN);
-};
diff --git a/dashboards/src/components/PanelGroupDialog/PanelGroupEditorForm.tsx b/dashboards/src/components/PanelGroupDialog/PanelGroupEditorForm.tsx
index 241379cc..60c4f5da 100644
--- a/dashboards/src/components/PanelGroupDialog/PanelGroupEditorForm.tsx
+++ b/dashboards/src/components/PanelGroupDialog/PanelGroupEditorForm.tsx
@@ -27,14 +27,28 @@ export function PanelGroupEditorForm(props: PanelGroupEditorFormProps): ReactEle
const [title, setTitle] = useState(initialValues.title);
const [isCollapsed, setIsCollapsed] = useState(initialValues.isCollapsed);
const [repeatVariable, setRepeatVariable] = useState(initialValues.repeatVariable);
+ const [layoutKind, setLayoutKind] = useState<'Grid' | 'Tabs'>(initialValues.layoutKind);
const handleSubmit: FormEventHandler = (e) => {
e.preventDefault();
- onSubmit({ title, isCollapsed, repeatVariable });
+ onSubmit({ title, isCollapsed, repeatVariable, layoutKind });
};
return (
);
diff --git a/dashboards/src/components/TabLayout/TabBar.tsx b/dashboards/src/components/TabLayout/TabBar.tsx
new file mode 100644
index 00000000..a2754a86
--- /dev/null
+++ b/dashboards/src/components/TabLayout/TabBar.tsx
@@ -0,0 +1,201 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Box, IconButton, Tab, Tabs, Tooltip } from '@mui/material';
+import { PanelGroupId } from '@perses-dev/spec';
+import ArrowLeftIcon from 'mdi-material-ui/ArrowLeft';
+import ArrowRightIcon from 'mdi-material-ui/ArrowRight';
+import DeleteIcon from 'mdi-material-ui/DeleteOutline';
+import PencilIcon from 'mdi-material-ui/PencilOutline';
+import PlusIcon from 'mdi-material-ui/Plus';
+import { ReactElement, SyntheticEvent, useCallback, useState } from 'react';
+import { TabState } from '../../model';
+import { TabEditorDialog } from './TabEditorDialog';
+
+const TAB_HEIGHT = 49;
+
+export interface TabBarProps {
+ panelGroupId: PanelGroupId;
+ tabs: TabState[];
+ activeTab: number;
+ defaultTab: number;
+ isEditMode: boolean;
+ onTabChange: (index: number) => void;
+ onTabRename?: (tabIndex: number, name: string) => void;
+ onTabReorder?: (fromIndex: number, toIndex: number) => void;
+ onSetDefaultTab?: (tabIndex: number) => void;
+ onAddTab?: (name: string) => void;
+ onRemoveTab?: (tabIndex: number) => void;
+}
+
+export function TabBar(props: TabBarProps): ReactElement {
+ const {
+ tabs,
+ activeTab,
+ defaultTab,
+ isEditMode,
+ onTabChange,
+ onTabRename,
+ onTabReorder,
+ onSetDefaultTab,
+ onAddTab,
+ onRemoveTab,
+ } = props;
+
+ const [editingTabIndex, setEditingTabIndex] = useState(null);
+
+ const handleChange = (_event: SyntheticEvent, newValue: number): void => {
+ onTabChange(newValue);
+ };
+
+ const handleAddTab = useCallback((): void => {
+ const newName = `Tab ${tabs.length + 1}`;
+ onAddTab?.(newName);
+ }, [tabs.length, onAddTab]);
+
+ const handleDialogSubmit = useCallback(
+ (name: string, isDefault: boolean): void => {
+ if (editingTabIndex === null) return;
+ onTabRename?.(editingTabIndex, name);
+ if (isDefault && editingTabIndex !== defaultTab) {
+ onSetDefaultTab?.(editingTabIndex);
+ }
+ },
+ [editingTabIndex, defaultTab, onTabRename, onSetDefaultTab]
+ );
+
+ const editingTab = editingTabIndex !== null ? tabs[editingTabIndex] : undefined;
+
+ if (!isEditMode) {
+ return (
+
+
+ {tabs.map((tab, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {tabs.map((tab, index) => (
+
+ {tab.name}
+
+ {
+ e.stopPropagation();
+ setEditingTabIndex(index);
+ }}
+ data-testid={`tab-edit-${index}`}
+ aria-label={`Edit ${tab.name}`}
+ >
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onTabReorder?.(index, index - 1);
+ }}
+ data-testid={`tab-move-left-${index}`}
+ aria-label={`Move ${tab.name} left`}
+ >
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onTabReorder?.(index, index + 1);
+ }}
+ data-testid={`tab-move-right-${index}`}
+ aria-label={`Move ${tab.name} right`}
+ >
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onRemoveTab?.(index);
+ }}
+ data-testid={`tab-delete-${index}`}
+ aria-label={`Delete ${tab.name}`}
+ >
+
+
+
+
+
+ }
+ />
+ ))}
+
+
+
+
+
+
+
+ setEditingTabIndex(null)}
+ />
+
+ );
+}
diff --git a/dashboards/src/components/TabLayout/TabEditorDialog.tsx b/dashboards/src/components/TabLayout/TabEditorDialog.tsx
new file mode 100644
index 00000000..4cc26119
--- /dev/null
+++ b/dashboards/src/components/TabLayout/TabEditorDialog.tsx
@@ -0,0 +1,84 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { FormEventHandler, ReactElement, useEffect, useState } from 'react';
+import { Checkbox, FormControl, FormControlLabel, TextField } from '@mui/material';
+import { Dialog } from '@perses-dev/components';
+
+const tabEditorFormId = 'tab-editor-form';
+
+export interface TabEditorDialogProps {
+ open: boolean;
+ tabName: string;
+ isDefault: boolean;
+ onSubmit: (name: string, isDefault: boolean) => void;
+ onClose: () => void;
+}
+
+export function TabEditorDialog(props: TabEditorDialogProps): ReactElement {
+ const { open, tabName, isDefault, onSubmit, onClose } = props;
+
+ const [name, setName] = useState(tabName);
+ const [defaultTab, setDefaultTab] = useState(isDefault);
+
+ useEffect(() => {
+ if (open) {
+ setName(tabName);
+ setDefaultTab(isDefault);
+ }
+ }, [open, tabName, isDefault]);
+
+ const handleSubmit: FormEventHandler = (e) => {
+ e.preventDefault();
+ if (name.trim() !== '') {
+ onSubmit(name.trim(), defaultTab);
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/dashboards/src/components/TabLayout/TabLayout.tsx b/dashboards/src/components/TabLayout/TabLayout.tsx
new file mode 100644
index 00000000..f9d48869
--- /dev/null
+++ b/dashboards/src/components/TabLayout/TabLayout.tsx
@@ -0,0 +1,214 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Collapse, useTheme } from '@mui/material';
+import { PanelGroupId } from '@perses-dev/spec';
+import { ErrorAlert, ErrorBoundary } from '@perses-dev/components';
+import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
+import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout';
+import { useEditMode, usePanelGroup, useTabActions, useViewPanelGroup } from '../../context';
+import {
+ GRID_LAYOUT_COLS,
+ GRID_LAYOUT_MARGIN,
+ GRID_LAYOUT_ROW_HEIGHT,
+ GRID_LAYOUT_SMALL_BREAKPOINT,
+ calculateGridItemWidth,
+} from '../../constants';
+import { TabPanelGroup, PanelGroupItemLayout } from '../../model';
+import { PanelOptions } from '../Panel';
+import { GridContainer } from '../GridLayout/GridContainer';
+import { GridItemContent } from '../GridLayout/GridItemContent';
+import { GridTitle } from '../GridLayout/GridTitle';
+import { TabBar } from './TabBar';
+
+const ResponsiveGridLayout = WidthProvider(Responsive);
+
+export interface TabLayoutProps {
+ panelGroupId: PanelGroupId;
+ panelOptions?: PanelOptions;
+ panelFullHeight?: number;
+}
+
+export function TabLayout(props: TabLayoutProps): ReactElement {
+ const { panelGroupId, panelOptions, panelFullHeight } = props;
+ const panelGroup = usePanelGroup(panelGroupId);
+ if (panelGroup.layoutKind !== 'Tabs') {
+ throw new Error(`TabLayout expects a Tabs panel group, got ${panelGroup.layoutKind}`);
+ }
+ const groupDefinition: TabPanelGroup = panelGroup;
+
+ const { setActiveTab, updateTabLayouts, updateTabName, setDefaultTab, addTab, removeTab, reorderTabs } =
+ useTabActions(panelGroupId);
+ const { isEditMode } = useEditMode();
+ const viewPanelItemId = useViewPanelGroup();
+
+ const [isOpen, setIsOpen] = useState(!groupDefinition.isCollapsed);
+ const [gridColWidth, setGridColWidth] = useState(0);
+
+ const hasViewPanel = viewPanelItemId?.panelGroupId === panelGroupId;
+ const isGridDisplayed = !viewPanelItemId || hasViewPanel;
+
+ const tabParamKey = `perses-tab-${panelGroupId}`;
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const urlTab = params.get(tabParamKey);
+ if (urlTab !== null) {
+ const tabIndex = parseInt(urlTab, 10);
+ if (!isNaN(tabIndex) && tabIndex >= 0 && tabIndex < groupDefinition.tabs.length) {
+ setActiveTab(tabIndex);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // If view panel is in this group, expand it
+ useEffect(() => {
+ if (hasViewPanel) {
+ setIsOpen(true);
+ }
+ }, [hasViewPanel]);
+
+ const handleTabChange = useCallback(
+ (index: number): void => {
+ setActiveTab(index);
+ const params = new URLSearchParams(window.location.search);
+ params.set(tabParamKey, String(index));
+ const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`;
+ history.replaceState(null, '', newUrl);
+ },
+ [setActiveTab, tabParamKey]
+ );
+
+ const activeTabIndex = groupDefinition.activeTab;
+ const activeTabState = groupDefinition.tabs[activeTabIndex];
+
+ const handleLayoutChange = useCallback(
+ (_currentLayout: Layout[], allLayouts: Layouts): void => {
+ const smallLayout = allLayouts[GRID_LAYOUT_SMALL_BREAKPOINT];
+ if (smallLayout && !hasViewPanel) {
+ updateTabLayouts(activeTabIndex, smallLayout as PanelGroupItemLayout[]);
+ }
+ },
+ [activeTabIndex, hasViewPanel, updateTabLayouts]
+ );
+
+ const handleWidthChange = useCallback(
+ (containerWidth: number, margin: [number, number], cols: number, containerPadding: [number, number]): void => {
+ const marginX = margin[0];
+ const marginWidth = marginX * (cols - 1);
+ const containerPaddingWidth = containerPadding[0] * 2;
+ setGridColWidth((containerWidth - marginWidth - containerPaddingWidth) / cols);
+ },
+ []
+ );
+
+ const theme = useTheme();
+
+ // Get the active tab's item layouts
+ const itemLayouts: PanelGroupItemLayout[] = useMemo(() => {
+ if (!activeTabState) return [];
+
+ if (viewPanelItemId?.panelGroupId === panelGroupId) {
+ const itemLayoutViewed = viewPanelItemId.panelGroupItemLayoutId;
+ return activeTabState.itemLayouts.map((itemLayout) => {
+ if (itemLayout.i === itemLayoutViewed) {
+ const rowTitleHeight = 40 + 8;
+ return {
+ h: Math.round(((panelFullHeight ?? window.innerHeight) - rowTitleHeight) / (GRID_LAYOUT_ROW_HEIGHT + GRID_LAYOUT_MARGIN)),
+ i: itemLayoutViewed,
+ w: 48,
+ x: 0,
+ y: 0,
+ } as PanelGroupItemLayout;
+ }
+ return itemLayout;
+ });
+ }
+ return activeTabState.itemLayouts;
+ }, [activeTabState, viewPanelItemId, panelGroupId, panelFullHeight]);
+
+ const itemLayoutViewed = hasViewPanel ? viewPanelItemId?.panelGroupItemLayoutId : undefined;
+
+ return (
+
+ {groupDefinition.title && (
+ setIsOpen((current) => !current) }
+ }
+ />
+ )}
+
+
+ {activeTabState && (
+
+ {itemLayouts.map(({ i, w }) => (
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/dashboards/src/components/TabLayout/index.ts b/dashboards/src/components/TabLayout/index.ts
new file mode 100644
index 00000000..fca724aa
--- /dev/null
+++ b/dashboards/src/components/TabLayout/index.ts
@@ -0,0 +1,16 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './TabBar';
+export * from './TabEditorDialog';
+export * from './TabLayout';
diff --git a/dashboards/src/components/index.ts b/dashboards/src/components/index.ts
index 65df3f48..8c2d75c4 100644
--- a/dashboards/src/components/index.ts
+++ b/dashboards/src/components/index.ts
@@ -27,6 +27,7 @@ export * from './EditJsonDialog';
export * from './EditJsonButton';
export * from './EmptyDashboard';
export * from './GridLayout';
+export * from './TabLayout';
export * from './LeaveDialog';
export * from './Panel';
export * from './PanelDrawer';
diff --git a/dashboards/src/constants/grid-layout-config.ts b/dashboards/src/constants/grid-layout-config.ts
index d22ec797..e35229e5 100644
--- a/dashboards/src/constants/grid-layout-config.ts
+++ b/dashboards/src/constants/grid-layout-config.ts
@@ -14,3 +14,13 @@
export const GRID_LAYOUT_COLS = { sm: 24, xxs: 2 } as const;
export const GRID_LAYOUT_SMALL_BREAKPOINT = 'sm' as const;
+
+export const GRID_LAYOUT_MARGIN = 10;
+
+export const GRID_LAYOUT_ROW_HEIGHT = 30;
+
+// 0 * Infinity === NaN, which causes problems with resize constraints
+export function calculateGridItemWidth(w: number, colWidth: number, margin = GRID_LAYOUT_MARGIN): number {
+ if (!Number.isFinite(w)) return w;
+ return Math.round(colWidth * w + Math.max(0, w - 1) * margin);
+}
diff --git a/dashboards/src/context/DashboardProvider/dashboard-provider-api.ts b/dashboards/src/context/DashboardProvider/dashboard-provider-api.ts
index 4e5631fe..e31d91b7 100644
--- a/dashboards/src/context/DashboardProvider/dashboard-provider-api.ts
+++ b/dashboards/src/context/DashboardProvider/dashboard-provider-api.ts
@@ -13,7 +13,13 @@
import { useCallback, useMemo } from 'react';
import { DurationString, Link, PanelDefinition, PanelGroupId } from '@perses-dev/spec';
-import { DashboardResource, PanelGroupDefinition, PanelGroupItemId, PanelGroupItemLayout } from '../../model';
+import {
+ DashboardResource,
+ PanelGroupDefinition,
+ PanelGroupItemId,
+ PanelGroupItemLayout,
+ getGroupItemPanelKeys,
+} from '../../model';
import { DashboardStoreState, useDashboardStore } from './DashboardProvider';
import { DeletePanelGroupDialogState } from './delete-panel-group-slice';
import { PanelGroupEditor } from './panel-group-editor-slice';
@@ -82,6 +88,41 @@ export function useDashboardLinksActions(): DashboardLinksActions {
return useDashboardStore(selectDashboardLinksActions);
}
+const selectTabActions = (state: DashboardStoreState) => ({
+ setActiveTab: state.setActiveTab,
+ updateTabLayouts: state.updateTabLayouts,
+ updateTabName: state.updateTabName,
+ setDefaultTab: state.setDefaultTab,
+ addTab: state.addTab,
+ removeTab: state.removeTab,
+ reorderTabs: state.reorderTabs,
+});
+
+export function useTabActions(panelGroupId: PanelGroupId): {
+ setActiveTab: (tabIndex: number) => void;
+ updateTabLayouts: (tabIndex: number, itemLayouts: PanelGroupItemLayout[]) => void;
+ updateTabName: (tabIndex: number, name: string) => void;
+ setDefaultTab: (tabIndex: number) => void;
+ addTab: (name: string) => void;
+ removeTab: (tabIndex: number) => void;
+ reorderTabs: (fromIndex: number, toIndex: number) => void;
+} {
+ const actions = useDashboardStore(selectTabActions);
+ return useMemo(
+ () => ({
+ setActiveTab: (tabIndex: number) => actions.setActiveTab(panelGroupId, tabIndex),
+ updateTabLayouts: (tabIndex: number, itemLayouts: PanelGroupItemLayout[]) =>
+ actions.updateTabLayouts(panelGroupId, tabIndex, itemLayouts),
+ updateTabName: (tabIndex: number, name: string) => actions.updateTabName(panelGroupId, tabIndex, name),
+ setDefaultTab: (tabIndex: number) => actions.setDefaultTab(panelGroupId, tabIndex),
+ addTab: (name: string) => actions.addTab(panelGroupId, name),
+ removeTab: (tabIndex: number) => actions.removeTab(panelGroupId, tabIndex),
+ reorderTabs: (fromIndex: number, toIndex: number) => actions.reorderTabs(panelGroupId, fromIndex, toIndex),
+ }),
+ [actions, panelGroupId]
+ );
+}
+
const selectPanelGroupOrder = (state: DashboardStoreState): number[] => state.panelGroupOrder;
/**
* Returns an array of PanelGroupIds in the order they appear in the dashboard.
@@ -127,7 +168,7 @@ const selectPanelGroupActions: ({
openAddPanel,
updatePanelGroupLayouts,
}: DashboardStoreState) => {
- updatePanelGroupLayouts: (panelGroupId: PanelGroupId, itemLayouts: PanelGroupDefinition['itemLayouts']) => void;
+ updatePanelGroupLayouts: (panelGroupId: PanelGroupId, itemLayouts: PanelGroupItemLayout[]) => void;
openEditPanelGroup: (panelGroupId: PanelGroupId) => void;
openAddPanel: (panelGroupId?: PanelGroupId) => void;
deletePanelGroup: (panelGroupId: PanelGroupId) => void;
@@ -252,7 +293,9 @@ export function usePanelKey(panelGroupItemId?: PanelGroupItemId): string | undef
return undefined;
}
- return store.panelGroups[panelGroupItemId.panelGroupId]?.itemPanelKeys[panelGroupItemId.panelGroupItemLayoutId];
+ const group = store.panelGroups[panelGroupItemId.panelGroupId];
+ if (group === undefined) return undefined;
+ return getGroupItemPanelKeys(group)[panelGroupItemId.panelGroupItemLayoutId];
},
[panelGroupItemId]
)
@@ -268,7 +311,9 @@ export function usePanel(panelGroupItemId: PanelGroupItemId): PanelDefinition {
const panel = useDashboardStore(
useCallback(
(store) => {
- const panelKey = store.panelGroups[panelGroupId]?.itemPanelKeys[panelGroupLayoutId];
+ const group = store.panelGroups[panelGroupId];
+ if (group === undefined) return;
+ const panelKey = getGroupItemPanelKeys(group)[panelGroupLayoutId];
if (panelKey === undefined) return;
return store.panels[panelKey];
},
diff --git a/dashboards/src/context/DashboardProvider/delete-panel-group-slice.ts b/dashboards/src/context/DashboardProvider/delete-panel-group-slice.ts
index 33eabdff..e562c850 100644
--- a/dashboards/src/context/DashboardProvider/delete-panel-group-slice.ts
+++ b/dashboards/src/context/DashboardProvider/delete-panel-group-slice.ts
@@ -13,6 +13,7 @@
import { StateCreator } from 'zustand';
import { PanelGroupId } from '@perses-dev/spec';
+import { getGroupItemPanelKeys } from '../../model';
import { Middleware } from './common';
import { PanelGroupSlice } from './panel-group-slice';
import { PanelSlice } from './panel-slice';
@@ -53,7 +54,7 @@ export const createDeletePanelGroupSlice: StateCreator<
}
// Get the panel keys for all the panel items in the group we're going to delete
- const panelKeys = Object.values(group.itemPanelKeys);
+ const panelKeys = Object.values(getGroupItemPanelKeys(group));
set((draft) => {
// Delete the panel group which also deletes all its items
@@ -93,7 +94,7 @@ export const createDeletePanelGroupSlice: StateCreator<
function getUsedPanelKeys(panelGroups: PanelGroupSlice['panelGroups']): Set {
const usedPanelKeys = new Set();
for (const group of Object.values(panelGroups)) {
- for (const panelKey of Object.values(group.itemPanelKeys)) {
+ for (const panelKey of Object.values(getGroupItemPanelKeys(group))) {
usedPanelKeys.add(panelKey);
}
}
diff --git a/dashboards/src/context/DashboardProvider/delete-panel-slice.ts b/dashboards/src/context/DashboardProvider/delete-panel-slice.ts
index 11cbd9d9..cfff4860 100644
--- a/dashboards/src/context/DashboardProvider/delete-panel-slice.ts
+++ b/dashboards/src/context/DashboardProvider/delete-panel-slice.ts
@@ -12,7 +12,7 @@
// limitations under the License.
import { StateCreator } from 'zustand';
-import { PanelGroupItemId } from '../../model';
+import { PanelGroupItemId, getGroupItemPanelKeys, findTabContainingItem } from '../../model';
import { Middleware } from './common';
import { PanelGroupSlice } from './panel-group-slice';
import { PanelSlice } from './panel-slice';
@@ -67,15 +67,31 @@ export function createDeletePanelSlice(): StateCreator<
if (existingGroup === undefined) {
throw new Error(`Missing panel group ${panelGroupId}`);
}
- const existingLayoutIdx = existingGroup.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId);
- const existingPanelKey = existingGroup.itemPanelKeys[panelGroupLayoutId];
- if (existingLayoutIdx === -1 || existingPanelKey === undefined) {
+ const existingPanelKey = getGroupItemPanelKeys(existingGroup)[panelGroupLayoutId];
+ if (existingPanelKey === undefined) {
throw new Error(`Missing panel group item ${panelGroupLayoutId}`);
}
// remove panel from panel group
- existingGroup.itemLayouts.splice(existingLayoutIdx, 1);
- delete existingGroup.itemPanelKeys[panelGroupLayoutId];
+ if (existingGroup.layoutKind === 'Grid') {
+ const existingLayoutIdx = existingGroup.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId);
+ if (existingLayoutIdx === -1) {
+ throw new Error(`Missing panel group item layout ${panelGroupLayoutId}`);
+ }
+ existingGroup.itemLayouts.splice(existingLayoutIdx, 1);
+ delete existingGroup.itemPanelKeys[panelGroupLayoutId];
+ } else {
+ const tab = findTabContainingItem(existingGroup, panelGroupLayoutId);
+ if (tab === undefined) {
+ throw new Error(`Missing panel group item in tabs ${panelGroupLayoutId}`);
+ }
+ const existingLayoutIdx = tab.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId);
+ if (existingLayoutIdx === -1) {
+ throw new Error(`Missing panel group item layout ${panelGroupLayoutId}`);
+ }
+ tab.itemLayouts.splice(existingLayoutIdx, 1);
+ delete tab.itemPanelKeys[panelGroupLayoutId];
+ }
// See if panel key is still used and if not, delete it
if (isPanelKeyStillUsed(draft.panelGroups, existingPanelKey) === false) {
@@ -93,7 +109,7 @@ export function createDeletePanelSlice(): StateCreator<
throw new Error(`Panel group not found ${panelGroupId}`);
}
- const panelKey = panelGroup.itemPanelKeys[panelGroupLayoutId];
+ const panelKey = getGroupItemPanelKeys(panelGroup)[panelGroupLayoutId];
if (panelKey === undefined) {
throw new Error(`Could not find Panel Group item ${panelGroupLayoutId}`);
}
@@ -123,7 +139,7 @@ export function createDeletePanelSlice(): StateCreator<
// Helper function to determine if a panel key is still being used somewhere in Panel Groups
function isPanelKeyStillUsed(panelGroups: PanelGroupSlice['panelGroups'], panelKey: string): boolean {
for (const group of Object.values(panelGroups)) {
- const found = Object.values(group.itemPanelKeys).find((key) => key === panelKey);
+ const found = Object.values(getGroupItemPanelKeys(group)).find((key) => key === panelKey);
if (found !== undefined) {
return true;
}
diff --git a/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts b/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
index abbbd918..adf8f2cd 100644
--- a/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
+++ b/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts
@@ -12,7 +12,7 @@
// limitations under the License.
import { StateCreator } from 'zustand';
-import { PanelGroupItemId } from '../../model';
+import { PanelGroupItemId, getGroupItemPanelKeys, getGroupItemLayouts, findTabContainingItem } from '../../model';
import { generatePanelKey, insertPanelInLayout, UnpositionedPanelGroupItemLayout } from '../../utils/panelUtils';
import { generateId, Middleware } from './common';
import { PanelGroupSlice } from './panel-group-slice';
@@ -49,19 +49,19 @@ export function createDuplicatePanelSlice(): StateCreator<
if (group === undefined) {
throw new Error(`Missing panel group ${panelGroupId}`);
}
- const panelKey = group.itemPanelKeys[panelGroupLayoutId];
+ const panelKey = getGroupItemPanelKeys(group)[panelGroupLayoutId];
if (panelKey === undefined) {
throw new Error(`Could not find Panel Group item ${panelGroupItemId}`);
}
- // Find the panel to edit
+ // Find the panel to duplicate
const panelToDupe = panels[panelKey];
if (panelToDupe === undefined) {
throw new Error(`Cannot find Panel with key '${panelKey}'`);
}
// Find the layout for the item being duped
- const matchingLayout = group.itemLayouts.find((itemLayout) => {
+ const matchingLayout = getGroupItemLayouts(group).find((itemLayout) => {
return itemLayout.i === panelGroupLayoutId;
});
@@ -79,9 +79,17 @@ export function createDuplicatePanelSlice(): StateCreator<
h: matchingLayout.h,
};
- group.itemLayouts = insertPanelInLayout(duplicateLayout, matchingLayout, group.itemLayouts);
-
- group.itemPanelKeys[duplicateLayout.i] = dupePanelKey;
+ if (group.layoutKind === 'Grid') {
+ group.itemLayouts = insertPanelInLayout(duplicateLayout, matchingLayout, group.itemLayouts);
+ group.itemPanelKeys[duplicateLayout.i] = dupePanelKey;
+ } else {
+ const tab = findTabContainingItem(group, panelGroupLayoutId);
+ if (tab === undefined) {
+ throw new Error(`Cannot find tab containing item ${panelGroupLayoutId}`);
+ }
+ tab.itemLayouts = insertPanelInLayout(duplicateLayout, matchingLayout, tab.itemLayouts);
+ tab.itemPanelKeys[duplicateLayout.i] = dupePanelKey;
+ }
});
},
});
diff --git a/dashboards/src/context/DashboardProvider/panel-editor-slice.ts b/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
index cbbf1d0a..f1db7682 100644
--- a/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
+++ b/dashboards/src/context/DashboardProvider/panel-editor-slice.ts
@@ -15,7 +15,13 @@ import { Action } from '@perses-dev/components';
import { PanelEditorValues, PanelGroupId } from '@perses-dev/spec';
import { StateCreator } from 'zustand';
import { generatePanelKey, getYForNewRow } from '../../utils';
-import { PanelGroupDefinition, PanelGroupItemId, PanelGroupItemLayout } from '../../model';
+import {
+ PanelGroupDefinition,
+ PanelGroupItemId,
+ PanelGroupItemLayout,
+ getGroupItemPanelKeys,
+ findTabContainingItem,
+} from '../../model';
import { generateId, Middleware, createPanelDefinition } from './common';
import { PanelGroupSlice, addPanelGroup, createEmptyPanelGroup } from './panel-group-slice';
import { PanelSlice } from './panel-slice';
@@ -91,7 +97,8 @@ export function createPanelEditorSlice(): StateCreator<
// Figure out the panel key at that location
const { panelGroupId, panelGroupItemLayoutId: panelGroupLayoutId } = panelGroupItemId;
- const panelKey = panelGroups[panelGroupId]?.itemPanelKeys[panelGroupLayoutId];
+ const group = panelGroups[panelGroupId];
+ const panelKey = group !== undefined ? getGroupItemPanelKeys(group)[panelGroupLayoutId] : undefined;
if (panelKey === undefined) {
throw new Error(`Could not find Panel Group item ${panelGroupItemId}`);
}
@@ -124,16 +131,35 @@ export function createPanelEditorSlice(): StateCreator<
throw new Error(`Missing panel group ${panelGroupId}`);
}
- const existingLayoutIdx = existingGroup.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId);
- const existingLayout = existingGroup.itemLayouts[existingLayoutIdx];
- const existingPanelKey = existingGroup.itemPanelKeys[panelGroupLayoutId];
- if (existingLayoutIdx === -1 || existingLayout === undefined || existingPanelKey === undefined) {
- throw new Error(`Missing panel group item ${panelGroupLayoutId}`);
- }
+ // Find and remove the item from the old group
+ let existingLayout: PanelGroupItemLayout | undefined;
+ let existingPanelKey: string | undefined;
- // Remove item from the old group
- existingGroup.itemLayouts.splice(existingLayoutIdx, 1);
- delete existingGroup.itemPanelKeys[panelGroupLayoutId];
+ if (existingGroup.layoutKind === 'Grid') {
+ const existingLayoutIdx = existingGroup.itemLayouts.findIndex(
+ (layout) => layout.i === panelGroupLayoutId
+ );
+ existingLayout = existingGroup.itemLayouts[existingLayoutIdx];
+ existingPanelKey = existingGroup.itemPanelKeys[panelGroupLayoutId];
+ if (existingLayoutIdx === -1 || existingLayout === undefined || existingPanelKey === undefined) {
+ throw new Error(`Missing panel group item ${panelGroupLayoutId}`);
+ }
+ existingGroup.itemLayouts.splice(existingLayoutIdx, 1);
+ delete existingGroup.itemPanelKeys[panelGroupLayoutId];
+ } else {
+ const tab = findTabContainingItem(existingGroup, panelGroupLayoutId);
+ if (tab === undefined) {
+ throw new Error(`Missing panel group item ${panelGroupLayoutId}`);
+ }
+ const existingLayoutIdx = tab.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId);
+ existingLayout = tab.itemLayouts[existingLayoutIdx];
+ existingPanelKey = tab.itemPanelKeys[panelGroupLayoutId];
+ if (existingLayoutIdx === -1 || existingLayout === undefined || existingPanelKey === undefined) {
+ throw new Error(`Missing panel group item ${panelGroupLayoutId}`);
+ }
+ tab.itemLayouts.splice(existingLayoutIdx, 1);
+ delete tab.itemPanelKeys[panelGroupLayoutId];
+ }
// Add item to the end of the new group
const newGroup = state.panelGroups[next.groupId];
@@ -141,14 +167,30 @@ export function createPanelEditorSlice(): StateCreator<
throw new Error(`Could not find new group ${next.groupId}`);
}
- newGroup.itemLayouts.push({
- i: existingLayout.i,
- x: 0,
- y: getYForNewRow(newGroup),
- w: existingLayout.w,
- h: existingLayout.h,
- });
- newGroup.itemPanelKeys[existingLayout.i] = existingPanelKey;
+ if (newGroup.layoutKind === 'Grid') {
+ newGroup.itemLayouts.push({
+ i: existingLayout.i,
+ x: 0,
+ y: getYForNewRow({ itemLayouts: newGroup.itemLayouts }),
+ w: existingLayout.w,
+ h: existingLayout.h,
+ });
+ newGroup.itemPanelKeys[existingLayout.i] = existingPanelKey;
+ } else {
+ // Add to the active tab
+ const targetTab = newGroup.tabs[newGroup.activeTab] ?? newGroup.tabs[0];
+ if (targetTab === undefined) {
+ throw new Error(`No tabs in target group ${next.groupId}`);
+ }
+ targetTab.itemLayouts.push({
+ i: existingLayout.i,
+ x: 0,
+ y: getYForNewRow({ itemLayouts: targetTab.itemLayouts }),
+ w: existingLayout.w,
+ h: existingLayout.h,
+ });
+ targetTab.itemPanelKeys[existingLayout.i] = existingPanelKey;
+ }
});
},
close: () => {
@@ -191,15 +233,33 @@ export function createPanelEditorSlice(): StateCreator<
if (group === undefined) {
throw new Error(`Missing panel group ${next.groupId}`);
}
- const layout: PanelGroupItemLayout = {
- i: generateId().toString(),
- x: 0,
- y: getYForNewRow(group),
- w: 12,
- h: 6,
- };
- group.itemLayouts.push(layout);
- group.itemPanelKeys[layout.i] = panelKey;
+
+ if (group.layoutKind === 'Grid') {
+ const layout: PanelGroupItemLayout = {
+ i: generateId().toString(),
+ x: 0,
+ y: getYForNewRow({ itemLayouts: group.itemLayouts }),
+ w: 12,
+ h: 6,
+ };
+ group.itemLayouts.push(layout);
+ group.itemPanelKeys[layout.i] = panelKey;
+ } else {
+ // Add to the active tab
+ const targetTab = group.tabs[group.activeTab] ?? group.tabs[0];
+ if (targetTab === undefined) {
+ throw new Error(`No tabs in target group ${next.groupId}`);
+ }
+ const layout: PanelGroupItemLayout = {
+ i: generateId().toString(),
+ x: 0,
+ y: getYForNewRow({ itemLayouts: targetTab.itemLayouts }),
+ w: 12,
+ h: 6,
+ };
+ targetTab.itemLayouts.push(layout);
+ targetTab.itemPanelKeys[layout.i] = panelKey;
+ }
});
},
close: () => {
diff --git a/dashboards/src/context/DashboardProvider/panel-group-editor-slice.ts b/dashboards/src/context/DashboardProvider/panel-group-editor-slice.ts
index 250c808c..34b77357 100644
--- a/dashboards/src/context/DashboardProvider/panel-group-editor-slice.ts
+++ b/dashboards/src/context/DashboardProvider/panel-group-editor-slice.ts
@@ -13,7 +13,8 @@
import { StateCreator } from 'zustand';
import { PanelGroupId } from '@perses-dev/spec';
-import { Middleware } from './common';
+import { GridPanelGroup, TabPanelGroup } from '../../model';
+import { generateId, Middleware } from './common';
import { PanelGroupSlice, addPanelGroup, createEmptyPanelGroup } from './panel-group-slice';
export interface PanelGroupEditor {
@@ -27,6 +28,7 @@ export interface PanelGroupEditorValues {
title: string;
isCollapsed: boolean;
repeatVariable: string | undefined;
+ layoutKind: 'Grid' | 'Tabs';
}
/**
@@ -66,15 +68,31 @@ export const createPanelGroupEditorSlice: StateCreator<
title: '',
isCollapsed: false,
repeatVariable: '',
+ layoutKind: 'Grid',
},
applyChanges(next) {
- const newGroup = createEmptyPanelGroup();
- newGroup.title = next.title;
- newGroup.isCollapsed = next.isCollapsed;
- newGroup.repeatVariable = next.repeatVariable;
- set((draft) => {
- addPanelGroup(draft, newGroup);
- });
+ if (next.layoutKind === 'Tabs') {
+ const newGroup: TabPanelGroup = {
+ id: generateId(),
+ layoutKind: 'Tabs',
+ title: next.title,
+ isCollapsed: next.isCollapsed,
+ tabs: [{ name: 'Tab 1', itemLayouts: [], itemPanelKeys: {} }],
+ defaultTab: 0,
+ activeTab: 0,
+ };
+ set((draft) => {
+ addPanelGroup(draft, newGroup);
+ });
+ } else {
+ const newGroup = createEmptyPanelGroup();
+ newGroup.title = next.title;
+ newGroup.isCollapsed = next.isCollapsed;
+ newGroup.repeatVariable = next.repeatVariable;
+ set((draft) => {
+ addPanelGroup(draft, newGroup);
+ });
+ }
},
close() {
set((draft) => {
@@ -101,7 +119,8 @@ export const createPanelGroupEditorSlice: StateCreator<
initialValues: {
title: existingGroup.title ?? '',
isCollapsed: existingGroup.isCollapsed,
- repeatVariable: existingGroup.repeatVariable ?? '',
+ repeatVariable: (existingGroup.layoutKind === 'Grid' ? existingGroup.repeatVariable : undefined) ?? '',
+ layoutKind: existingGroup.layoutKind,
},
applyChanges(next) {
set((draft) => {
@@ -109,9 +128,51 @@ export const createPanelGroupEditorSlice: StateCreator<
if (group === undefined) {
throw new Error(`Panel group with Id ${panelGroupId} does not exist`);
}
- group.title = next.title;
- group.isCollapsed = next.isCollapsed;
- group.repeatVariable = next.repeatVariable;
+
+ // Handle layout kind conversion
+ if (next.layoutKind !== group.layoutKind) {
+ if (next.layoutKind === 'Tabs' && group.layoutKind === 'Grid') {
+ const tabGroup: TabPanelGroup = {
+ id: group.id,
+ layoutKind: 'Tabs',
+ title: next.title,
+ isCollapsed: next.isCollapsed,
+ tabs: [
+ {
+ name: 'Tab 1',
+ itemLayouts: group.itemLayouts,
+ itemPanelKeys: group.itemPanelKeys,
+ },
+ ],
+ defaultTab: 0,
+ activeTab: 0,
+ };
+ draft.panelGroups[panelGroupId] = tabGroup;
+ } else if (next.layoutKind === 'Grid' && group.layoutKind === 'Tabs') {
+ const allItemLayouts = group.tabs.flatMap((tab) => tab.itemLayouts);
+ const allItemPanelKeys: Record = Object.assign(
+ {},
+ ...group.tabs.map((tab) => tab.itemPanelKeys)
+ );
+ const gridGroup: GridPanelGroup = {
+ id: group.id,
+ layoutKind: 'Grid',
+ title: next.title,
+ isCollapsed: next.isCollapsed,
+ itemLayouts: allItemLayouts,
+ itemPanelKeys: allItemPanelKeys,
+ repeatVariable: next.repeatVariable,
+ };
+ draft.panelGroups[panelGroupId] = gridGroup;
+ }
+ } else {
+ // Same layout kind, just update properties
+ group.title = next.title;
+ group.isCollapsed = next.isCollapsed;
+ if (group.layoutKind === 'Grid') {
+ group.repeatVariable = next.repeatVariable;
+ }
+ }
});
},
close() {
diff --git a/dashboards/src/context/DashboardProvider/panel-group-slice.test.ts b/dashboards/src/context/DashboardProvider/panel-group-slice.test.ts
new file mode 100644
index 00000000..ed095e77
--- /dev/null
+++ b/dashboards/src/context/DashboardProvider/panel-group-slice.test.ts
@@ -0,0 +1,628 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { GridDefinition, TabDefinition } from '@perses-dev/spec';
+import { createStore, StoreApi } from 'zustand';
+import { immer } from 'zustand/middleware/immer';
+import { GridPanelGroup, TabPanelGroup } from '../../model';
+import {
+ convertLayoutsToPanelGroups,
+ createEmptyPanelGroup,
+ createPanelGroupSlice,
+ PanelGroupSlice,
+} from './panel-group-slice';
+
+describe('convertLayoutsToPanelGroups', () => {
+ it('converts a Grid layout to a GridPanelGroup with layoutKind Grid', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: {
+ display: {
+ title: 'My Grid',
+ collapse: { open: true },
+ },
+ items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/panel-a' } }],
+ repeatVariable: 'host',
+ },
+ };
+
+ const result = convertLayoutsToPanelGroups([gridLayout]);
+ const groups = Object.values(result.panelGroups);
+ expect(groups).toHaveLength(1);
+
+ const group = groups[0] as GridPanelGroup;
+ expect(group.layoutKind).toBe('Grid');
+ expect(group.title).toBe('My Grid');
+ expect(group.isCollapsed).toBe(false); // collapse.open === true means NOT collapsed
+ expect(group.repeatVariable).toBe('host');
+ expect(group.itemLayouts).toHaveLength(1);
+ expect(group.itemLayouts[0]).toMatchObject({ x: 0, y: 0, w: 12, h: 6 });
+ expect(Object.values(group.itemPanelKeys)).toEqual(['panel-a']);
+ });
+
+ it('converts a Tabs layout to a TabPanelGroup with layoutKind Tabs', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: {
+ title: 'My Tabs',
+ collapse: { open: false },
+ },
+ tabs: [
+ {
+ name: 'Tab A',
+ items: [{ x: 0, y: 0, width: 6, height: 4, content: { $ref: '#/spec/panels/panel-x' } }],
+ },
+ {
+ name: 'Tab B',
+ items: [
+ { x: 0, y: 0, width: 12, height: 8, content: { $ref: '#/spec/panels/panel-y' } },
+ { x: 0, y: 8, width: 6, height: 4, content: { $ref: '#/spec/panels/panel-z' } },
+ ],
+ },
+ ],
+ defaultTab: 1,
+ },
+ };
+
+ const result = convertLayoutsToPanelGroups([tabLayout]);
+ const groups = Object.values(result.panelGroups);
+ expect(groups).toHaveLength(1);
+
+ const group = groups[0] as TabPanelGroup;
+ expect(group.layoutKind).toBe('Tabs');
+ expect(group.title).toBe('My Tabs');
+ expect(group.isCollapsed).toBe(true); // collapse.open === false means collapsed
+ expect(group.defaultTab).toBe(1);
+ expect(group.activeTab).toBe(1);
+ expect(group.tabs).toHaveLength(2);
+
+ // First tab
+ expect(group.tabs[0]?.name).toBe('Tab A');
+ expect(group.tabs[0]?.itemLayouts).toHaveLength(1);
+ expect(group.tabs[0]?.itemLayouts[0]).toMatchObject({ x: 0, y: 0, w: 6, h: 4 });
+ expect(Object.values(group.tabs[0]?.itemPanelKeys ?? {})).toEqual(['panel-x']);
+
+ // Second tab
+ expect(group.tabs[1]?.name).toBe('Tab B');
+ expect(group.tabs[1]?.itemLayouts).toHaveLength(2);
+ expect(Object.values(group.tabs[1]?.itemPanelKeys ?? {})).toContain('panel-y');
+ expect(Object.values(group.tabs[1]?.itemPanelKeys ?? {})).toContain('panel-z');
+ });
+
+ it('handles mixed Grid and Tab layouts', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: {
+ items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/panel-a' } }],
+ },
+ };
+
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ tabs: [
+ {
+ name: 'Only Tab',
+ items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/panel-b' } }],
+ },
+ ],
+ },
+ };
+
+ const result = convertLayoutsToPanelGroups([gridLayout, tabLayout]);
+ const groups = Object.values(result.panelGroups);
+ expect(groups).toHaveLength(2);
+
+ // Order should match the input order
+ expect(groups[0]?.layoutKind).toBe('Grid');
+ expect(groups[1]?.layoutKind).toBe('Tabs');
+ });
+});
+
+describe('createEmptyPanelGroup', () => {
+ it('returns a GridPanelGroup with layoutKind Grid', () => {
+ const group = createEmptyPanelGroup();
+ expect(group.layoutKind).toBe('Grid');
+ expect(group.itemLayouts).toEqual([]);
+ expect(group.itemPanelKeys).toEqual({});
+ expect(group.isCollapsed).toBe(false);
+ });
+});
+
+/**
+ * Helper to create a real Zustand store with immer middleware for testing store actions.
+ */
+function createTestStore(layouts: Array): StoreApi {
+ return createStore()(immer(createPanelGroupSlice(layouts)));
+}
+
+describe('setActiveTab', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p2' } }] },
+ { name: 'Tab C', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p3' } }] },
+ ],
+ defaultTab: 0,
+ },
+ };
+
+ it('sets activeTab on a TabPanelGroup', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.activeTab).toBe(0);
+
+ store.getState().setActiveTab(panelGroupId, 2);
+
+ const updated = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(updated.activeTab).toBe(2);
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().setActiveTab(99999, 1);
+
+ // State should be unchanged
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().setActiveTab(panelGroupId, 1);
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+});
+
+describe('updateTabLayouts', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 6, height: 4, content: { $ref: '#/spec/panels/p2' } }] },
+ ],
+ },
+ };
+
+ it('updates the itemLayouts for a specific tab', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ const originalLayoutId = group.tabs[1]!.itemLayouts[0]!.i;
+
+ const newLayouts = [{ i: originalLayoutId, x: 2, y: 2, w: 8, h: 10 }];
+ store.getState().updateTabLayouts(panelGroupId, 1, newLayouts);
+
+ const updated = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(updated.tabs[1]!.itemLayouts).toEqual(newLayouts);
+ // Tab 0 should be unchanged
+ expect(updated.tabs[0]!.itemLayouts).toEqual(group.tabs[0]!.itemLayouts);
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().updateTabLayouts(99999, 0, []);
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when tabIndex is out of bounds', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ const groupBefore = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+
+ store.getState().updateTabLayouts(panelGroupId, 999, []);
+
+ const groupAfter = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(groupAfter.tabs).toEqual(groupBefore.tabs);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().updateTabLayouts(panelGroupId, 0, []);
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+});
+
+describe('updateTabName', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p2' } }] },
+ ],
+ },
+ };
+
+ it('updates the name of a tab at the given index', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().updateTabName(panelGroupId, 0, 'Renamed Tab');
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs[0]!.name).toBe('Renamed Tab');
+ expect(group.tabs[1]!.name).toBe('Tab B');
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().updateTabName(99999, 0, 'Renamed');
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().updateTabName(panelGroupId, 0, 'Renamed');
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+
+ it('does nothing when tabIndex is out of bounds', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().updateTabName(panelGroupId, 999, 'Renamed');
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs[0]!.name).toBe('Tab A');
+ expect(group.tabs[1]!.name).toBe('Tab B');
+ });
+});
+
+describe('setDefaultTab', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p2' } }] },
+ { name: 'Tab C', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p3' } }] },
+ ],
+ defaultTab: 0,
+ },
+ };
+
+ it('sets the default tab index', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().setDefaultTab(panelGroupId, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.defaultTab).toBe(2);
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().setDefaultTab(99999, 1);
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().setDefaultTab(panelGroupId, 0);
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+
+ it('does nothing when tabIndex is out of bounds', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().setDefaultTab(panelGroupId, -1);
+ const group1 = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group1.defaultTab).toBe(0);
+
+ store.getState().setDefaultTab(panelGroupId, 999);
+ const group2 = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group2.defaultTab).toBe(0);
+ });
+});
+
+describe('addTab', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [{ name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] }],
+ },
+ };
+
+ it('adds a new tab with the given name', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().addTab(panelGroupId, 'New Tab');
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs).toHaveLength(2);
+ expect(group.tabs[1]!.name).toBe('New Tab');
+ expect(group.tabs[1]!.itemLayouts).toEqual([]);
+ expect(group.tabs[1]!.itemPanelKeys).toEqual({});
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().addTab(99999, 'New Tab');
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().addTab(panelGroupId, 'New Tab');
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+});
+
+describe('removeTab', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p2' } }] },
+ { name: 'Tab C', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p3' } }] },
+ ],
+ defaultTab: 1,
+ },
+ };
+
+ it('removes the tab at the given index', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().removeTab(panelGroupId, 0);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs).toHaveLength(2);
+ expect(group.tabs[0]!.name).toBe('Tab B');
+ expect(group.tabs[1]!.name).toBe('Tab C');
+ });
+
+ it('does not remove the last remaining tab', () => {
+ const singleTabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ tabs: [
+ { name: 'Only Tab', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ ],
+ },
+ };
+ const store = createTestStore([singleTabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().removeTab(panelGroupId, 0);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs).toHaveLength(1);
+ expect(group.tabs[0]!.name).toBe('Only Tab');
+ });
+
+ it('adjusts activeTab when it is beyond the new length', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // Set activeTab to the last tab (index 2)
+ store.getState().setActiveTab(panelGroupId, 2);
+
+ store.getState().removeTab(panelGroupId, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.activeTab).toBe(1); // Clamped to new last index
+ });
+
+ it('adjusts defaultTab when it is beyond the new length', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // defaultTab is 1, remove tab at index 0
+ store.getState().removeTab(panelGroupId, 2);
+ store.getState().removeTab(panelGroupId, 1);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.defaultTab).toBe(0); // Clamped to new last index
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().removeTab(99999, 0);
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().removeTab(panelGroupId, 0);
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+});
+
+describe('reorderTabs', () => {
+ const tabLayout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display: { title: 'Test Tabs' },
+ tabs: [
+ { name: 'Tab A', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ { name: 'Tab B', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p2' } }] },
+ { name: 'Tab C', items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p3' } }] },
+ ],
+ defaultTab: 0,
+ },
+ };
+
+ it('moves a tab from one position to another', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().reorderTabs(panelGroupId, 0, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs[0]!.name).toBe('Tab B');
+ expect(group.tabs[1]!.name).toBe('Tab C');
+ expect(group.tabs[2]!.name).toBe('Tab A');
+ });
+
+ it('adjusts defaultTab when the default tab is moved', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // defaultTab is 0 (Tab A), move it to index 2
+ store.getState().reorderTabs(panelGroupId, 0, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.defaultTab).toBe(2);
+ });
+
+ it('adjusts defaultTab when a tab before the default is moved after it', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // Set defaultTab to index 1 (Tab B)
+ store.getState().setDefaultTab(panelGroupId, 1);
+ // Move Tab A (index 0) to index 2 (after the default)
+ store.getState().reorderTabs(panelGroupId, 0, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ // Tab B was at index 1, but Tab A was removed from before it, so it shifts to 0
+ expect(group.defaultTab).toBe(0);
+ });
+
+ it('adjusts defaultTab when a tab after the default is moved before it', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // Set defaultTab to index 1 (Tab B)
+ store.getState().setDefaultTab(panelGroupId, 1);
+ // Move Tab C (index 2) to index 0 (before the default)
+ store.getState().reorderTabs(panelGroupId, 2, 0);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ // Tab B was at index 1, Tab C was inserted before it, so Tab B shifts to 2
+ expect(group.defaultTab).toBe(2);
+ });
+
+ it('adjusts activeTab when the active tab is moved', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+ // Set activeTab to 0
+ store.getState().setActiveTab(panelGroupId, 0);
+ // Move Tab A (index 0) to index 2
+ store.getState().reorderTabs(panelGroupId, 0, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.activeTab).toBe(2);
+ });
+
+ it('does nothing when indices are out of bounds', () => {
+ const store = createTestStore([tabLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().reorderTabs(panelGroupId, -1, 2);
+
+ const group = store.getState().panelGroups[panelGroupId] as TabPanelGroup;
+ expect(group.tabs[0]!.name).toBe('Tab A');
+ expect(group.tabs[1]!.name).toBe('Tab B');
+ expect(group.tabs[2]!.name).toBe('Tab C');
+ });
+
+ it('does nothing when panelGroupId does not exist', () => {
+ const store = createTestStore([tabLayout]);
+ const stateBefore = store.getState();
+
+ store.getState().reorderTabs(99999, 0, 1);
+
+ expect(store.getState().panelGroups).toEqual(stateBefore.panelGroups);
+ });
+
+ it('does nothing when called on a Grid group', () => {
+ const gridLayout: GridDefinition = {
+ kind: 'Grid',
+ spec: { items: [{ x: 0, y: 0, width: 12, height: 6, content: { $ref: '#/spec/panels/p1' } }] },
+ };
+ const store = createTestStore([gridLayout]);
+ const panelGroupId = store.getState().panelGroupOrder[0]!;
+
+ store.getState().reorderTabs(panelGroupId, 0, 1);
+
+ const group = store.getState().panelGroups[panelGroupId];
+ expect(group!.layoutKind).toBe('Grid');
+ });
+});
diff --git a/dashboards/src/context/DashboardProvider/panel-group-slice.ts b/dashboards/src/context/DashboardProvider/panel-group-slice.ts
index a89de23e..d3f97628 100644
--- a/dashboards/src/context/DashboardProvider/panel-group-slice.ts
+++ b/dashboards/src/context/DashboardProvider/panel-group-slice.ts
@@ -14,7 +14,7 @@
import { getPanelKeyFromRef, LayoutDefinition, PanelGroupId } from '@perses-dev/spec';
import { StateCreator } from 'zustand';
import { WritableDraft } from 'immer';
-import { PanelGroupDefinition } from '../../model';
+import { GridPanelGroup, PanelGroupDefinition, PanelGroupItemLayout, TabPanelGroup, TabState } from '../../model';
import { generateId, Middleware } from './common';
/**
@@ -39,7 +39,50 @@ export interface PanelGroupSlice {
/**
* Update the item layouts for a panel group when, for example, a panel is moved or resized.
*/
- updatePanelGroupLayouts: (panelGroupId: PanelGroupId, itemLayouts: PanelGroupDefinition['itemLayouts']) => void;
+ updatePanelGroupLayouts: (panelGroupId: PanelGroupId, itemLayouts: PanelGroupItemLayout[]) => void;
+
+ /**
+ * Set the active tab index for a TabPanelGroup. No-op if the group doesn't exist or is not a Tabs group.
+ */
+ setActiveTab: (panelGroupId: PanelGroupId, tabIndex: number) => void;
+
+ /**
+ * Update the item layouts for a specific tab within a TabPanelGroup.
+ * No-op if the group doesn't exist, is not a Tabs group, or the tab index is out of bounds.
+ */
+ updateTabLayouts: (panelGroupId: PanelGroupId, tabIndex: number, itemLayouts: PanelGroupItemLayout[]) => void;
+
+ /**
+ * Update the name of a tab at the given index within a TabPanelGroup.
+ * No-op if the group doesn't exist, is not a Tabs group, or the tab index is out of bounds.
+ */
+ updateTabName: (panelGroupId: PanelGroupId, tabIndex: number, name: string) => void;
+
+ /**
+ * Set the default tab index for a TabPanelGroup.
+ * No-op if the group doesn't exist, is not a Tabs group, or the tab index is out of bounds.
+ */
+ setDefaultTab: (panelGroupId: PanelGroupId, tabIndex: number) => void;
+
+ /**
+ * Add a new empty tab with the given name to a TabPanelGroup.
+ * No-op if the group doesn't exist or is not a Tabs group.
+ */
+ addTab: (panelGroupId: PanelGroupId, name: string) => void;
+
+ /**
+ * Remove the tab at the given index from a TabPanelGroup.
+ * Will not remove the last remaining tab. Adjusts activeTab and defaultTab if needed.
+ * No-op if the group doesn't exist or is not a Tabs group.
+ */
+ removeTab: (panelGroupId: PanelGroupId, tabIndex: number) => void;
+
+ /**
+ * Reorder tabs by moving a tab from one index to another.
+ * Adjusts activeTab and defaultTab to follow their original tabs.
+ * No-op if the group doesn't exist, is not a Tabs group, or indices are out of bounds.
+ */
+ reorderTabs: (panelGroupId: PanelGroupId, fromIndex: number, toIndex: number) => void;
}
/**
@@ -77,7 +120,94 @@ export function createPanelGroupSlice(
if (group === undefined) {
throw new Error(`Cannot find panel group ${panelGroupId}`);
}
- group.itemLayouts = itemLayouts;
+ // Only Grid groups have direct itemLayouts to update
+ if (group.layoutKind === 'Grid') {
+ group.itemLayouts = itemLayouts;
+ }
+ });
+ },
+
+ setActiveTab(panelGroupId, tabIndex): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ group.activeTab = tabIndex;
+ });
+ },
+
+ updateTabLayouts(panelGroupId, tabIndex, itemLayouts): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ const tab = group.tabs[tabIndex];
+ if (tab === undefined) return;
+ tab.itemLayouts = itemLayouts;
+ });
+ },
+
+ updateTabName(panelGroupId, tabIndex, name): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ const tab = group.tabs[tabIndex];
+ if (tab === undefined) return;
+ tab.name = name;
+ });
+ },
+
+ setDefaultTab(panelGroupId, tabIndex): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ if (tabIndex < 0 || tabIndex >= group.tabs.length) return;
+ group.defaultTab = tabIndex;
+ });
+ },
+
+ addTab(panelGroupId, name): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ group.tabs.push({ name, itemLayouts: [], itemPanelKeys: {} });
+ });
+ },
+
+ removeTab(panelGroupId, tabIndex): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ if (group.tabs.length <= 1) return; // Don't remove the last tab
+ group.tabs.splice(tabIndex, 1);
+ // Adjust activeTab and defaultTab if needed
+ if (group.activeTab >= group.tabs.length) group.activeTab = group.tabs.length - 1;
+ if (group.defaultTab >= group.tabs.length) group.defaultTab = group.tabs.length - 1;
+ });
+ },
+
+ reorderTabs(panelGroupId, fromIndex, toIndex): void {
+ set((state) => {
+ const group = state.panelGroups[panelGroupId];
+ if (group === undefined || group.layoutKind !== 'Tabs') return;
+ if (fromIndex < 0 || fromIndex >= group.tabs.length || toIndex < 0 || toIndex >= group.tabs.length) return;
+ const [tab] = group.tabs.splice(fromIndex, 1);
+ if (tab === undefined) return;
+ group.tabs.splice(toIndex, 0, tab);
+ // Adjust defaultTab to follow the tab that was default
+ if (group.defaultTab === fromIndex) {
+ group.defaultTab = toIndex;
+ } else if (fromIndex < group.defaultTab && toIndex >= group.defaultTab) {
+ group.defaultTab--;
+ } else if (fromIndex > group.defaultTab && toIndex <= group.defaultTab) {
+ group.defaultTab++;
+ }
+ // Adjust activeTab similarly
+ if (group.activeTab === fromIndex) {
+ group.activeTab = toIndex;
+ } else if (fromIndex < group.activeTab && toIndex >= group.activeTab) {
+ group.activeTab--;
+ } else if (fromIndex > group.activeTab && toIndex <= group.activeTab) {
+ group.activeTab++;
+ }
});
},
});
@@ -89,35 +219,83 @@ export function convertLayoutsToPanelGroups(
// Convert the initial layouts from the JSON
const panelGroups: PanelGroupSlice['panelGroups'] = {};
const panelGroupIdOrder: PanelGroupSlice['panelGroupOrder'] = [];
+
for (const layout of layouts) {
- const itemLayouts: PanelGroupDefinition['itemLayouts'] = [];
- const itemPanelKeys: PanelGroupDefinition['itemPanelKeys'] = {};
-
- // Split layout information from panel keys to make it easier to update just layouts on move/resize of panels
- for (const item of layout.spec.items) {
- const panelGroupLayoutId = generateId().toString();
- itemLayouts.push({
- i: panelGroupLayoutId,
- w: item.width,
- h: item.height,
- x: item.x,
- y: item.y,
- });
- itemPanelKeys[panelGroupLayoutId] = getPanelKeyFromRef(item.content);
+ const panelGroupId = generateId();
+
+ switch (layout.kind) {
+ case 'Grid': {
+ const itemLayouts: PanelGroupItemLayout[] = [];
+ const itemPanelKeys: Record = {};
+
+ // Split layout information from panel keys to make it easier to update just layouts on move/resize of panels
+ for (const item of layout.spec.items) {
+ const panelGroupLayoutId = generateId().toString();
+ itemLayouts.push({
+ i: panelGroupLayoutId,
+ w: item.width,
+ h: item.height,
+ x: item.x,
+ y: item.y,
+ });
+ itemPanelKeys[panelGroupLayoutId] = getPanelKeyFromRef(item.content);
+ }
+
+ const gridGroup: GridPanelGroup = {
+ id: panelGroupId,
+ layoutKind: 'Grid',
+ isCollapsed: layout.spec.display?.collapse?.open === false,
+ repeatVariable: layout.spec.repeatVariable,
+ title: layout.spec.display?.title,
+ itemLayouts,
+ itemPanelKeys,
+ };
+ panelGroups[panelGroupId] = gridGroup;
+ break;
+ }
+
+ case 'Tabs': {
+ const tabs: TabState[] = layout.spec.tabs.map((tabDef) => {
+ const tabItemLayouts: PanelGroupItemLayout[] = [];
+ const tabItemPanelKeys: Record = {};
+
+ for (const item of tabDef.items) {
+ const panelGroupLayoutId = generateId().toString();
+ tabItemLayouts.push({
+ i: panelGroupLayoutId,
+ w: item.width,
+ h: item.height,
+ x: item.x,
+ y: item.y,
+ });
+ tabItemPanelKeys[panelGroupLayoutId] = getPanelKeyFromRef(item.content);
+ }
+
+ return {
+ name: tabDef.name,
+ itemLayouts: tabItemLayouts,
+ itemPanelKeys: tabItemPanelKeys,
+ };
+ });
+
+ const defaultTab = layout.spec.defaultTab ?? 0;
+ const tabGroup: TabPanelGroup = {
+ id: panelGroupId,
+ layoutKind: 'Tabs',
+ isCollapsed: layout.spec.display?.collapse?.open === false,
+ title: layout.spec.display?.title,
+ tabs,
+ defaultTab,
+ activeTab: defaultTab,
+ };
+ panelGroups[panelGroupId] = tabGroup;
+ break;
+ }
}
- // Create the panel group and keep track of the ID order
- const panelGroupId = generateId();
- panelGroups[panelGroupId] = {
- id: panelGroupId,
- isCollapsed: layout.spec.display?.collapse?.open === false,
- repeatVariable: layout.spec.repeatVariable,
- title: layout.spec.display?.title,
- itemLayouts,
- itemPanelKeys,
- };
panelGroupIdOrder.push(panelGroupId);
}
+
return {
panelGroups,
panelGroupOrder: panelGroupIdOrder,
@@ -127,9 +305,10 @@ export function convertLayoutsToPanelGroups(
/**
* Private helper function for creating an empty panel group.
*/
-export function createEmptyPanelGroup(): PanelGroupDefinition {
+export function createEmptyPanelGroup(): GridPanelGroup {
return {
id: generateId(),
+ layoutKind: 'Grid',
title: undefined,
isCollapsed: false,
itemLayouts: [],
diff --git a/dashboards/src/context/DashboardProvider/view-panel-slice.ts b/dashboards/src/context/DashboardProvider/view-panel-slice.ts
index aa030bde..41aafd05 100644
--- a/dashboards/src/context/DashboardProvider/view-panel-slice.ts
+++ b/dashboards/src/context/DashboardProvider/view-panel-slice.ts
@@ -13,7 +13,7 @@
import { StateCreator } from 'zustand';
import { PanelGroupId } from '@perses-dev/spec';
-import { PanelGroupDefinition, PanelGroupItemId } from '../../model';
+import { PanelGroupDefinition, PanelGroupItemId, getGroupItemPanelKeys } from '../../model';
import { Middleware } from './common';
import { PanelGroupSlice } from './panel-group-slice';
@@ -94,7 +94,7 @@ function findPanelGroupItemIdOfPanelRef(
panelRef: VirtualPanelRef
): PanelGroupItemId | undefined {
for (const panelGroup of Object.values(panelGroups)) {
- const itemPanel = Object.entries(panelGroup.itemPanelKeys ?? []).find(([_, value]) => value === panelRef.ref);
+ const itemPanel = Object.entries(getGroupItemPanelKeys(panelGroup)).find(([_, value]) => value === panelRef.ref);
if (itemPanel) {
const [key] = itemPanel;
return {
@@ -117,7 +117,7 @@ function findPanelRefOfPanelGroupItemId(
}
const panelGroup = panelGroups[panelGroupItemId.panelGroupId];
if (panelGroup) {
- const panelRef = panelGroup.itemPanelKeys[panelGroupItemId.panelGroupItemLayoutId];
+ const panelRef = getGroupItemPanelKeys(panelGroup)[panelGroupItemId.panelGroupItemLayoutId];
if (panelRef) {
return { ref: panelRef, repeatVariable: panelGroupItemId.repeatVariable };
}
diff --git a/dashboards/src/context/useDashboard.test.ts b/dashboards/src/context/useDashboard.test.ts
new file mode 100644
index 00000000..fd08bfba
--- /dev/null
+++ b/dashboards/src/context/useDashboard.test.ts
@@ -0,0 +1,128 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { GridDefinition, TabDefinition } from '@perses-dev/spec';
+import { GridPanelGroup, TabPanelGroup } from '../model';
+import { convertPanelGroupsToLayouts } from './useDashboard';
+
+describe('convertPanelGroupsToLayouts', () => {
+ it('converts a GridPanelGroup back to a GridDefinition', () => {
+ const panelGroups: Record = {
+ 1: {
+ id: 1,
+ layoutKind: 'Grid',
+ isCollapsed: false,
+ title: 'My Grid',
+ repeatVariable: 'host',
+ itemLayouts: [{ i: 'layout-1', x: 0, y: 0, w: 12, h: 6 }],
+ itemPanelKeys: {
+ 'layout-1': 'panel-a',
+ },
+ },
+ };
+ const order = [1];
+
+ const result = convertPanelGroupsToLayouts(panelGroups, order);
+ expect(result).toHaveLength(1);
+
+ const layout = result[0] as GridDefinition;
+ expect(layout.kind).toBe('Grid');
+ expect(layout.spec.display?.title).toBe('My Grid');
+ expect(layout.spec.display?.collapse?.open).toBe(true);
+ expect(layout.spec.repeatVariable).toBe('host');
+ expect(layout.spec.items).toHaveLength(1);
+ expect(layout.spec.items[0]).toMatchObject({
+ x: 0,
+ y: 0,
+ width: 12,
+ height: 6,
+ });
+ });
+
+ it('converts a TabPanelGroup back to a TabDefinition', () => {
+ const panelGroups: Record = {
+ 2: {
+ id: 2,
+ layoutKind: 'Tabs',
+ isCollapsed: true,
+ title: 'My Tabs',
+ defaultTab: 1,
+ activeTab: 0,
+ tabs: [
+ {
+ name: 'Tab A',
+ itemLayouts: [{ i: 'tab1-l1', x: 0, y: 0, w: 6, h: 4 }],
+ itemPanelKeys: { 'tab1-l1': 'panel-x' },
+ },
+ {
+ name: 'Tab B',
+ itemLayouts: [{ i: 'tab2-l1', x: 0, y: 0, w: 12, h: 8 }],
+ itemPanelKeys: { 'tab2-l1': 'panel-y' },
+ },
+ ],
+ },
+ };
+ const order = [2];
+
+ const result = convertPanelGroupsToLayouts(panelGroups, order);
+ expect(result).toHaveLength(1);
+
+ const layout = result[0] as TabDefinition;
+ expect(layout.kind).toBe('Tabs');
+ expect(layout.spec.display?.title).toBe('My Tabs');
+ expect(layout.spec.display?.collapse?.open).toBe(false); // isCollapsed === true => open === false
+ expect(layout.spec.defaultTab).toBe(1);
+ expect(layout.spec.tabs).toHaveLength(2);
+ expect(layout.spec.tabs[0]?.name).toBe('Tab A');
+ expect(layout.spec.tabs[0]?.items).toHaveLength(1);
+ expect(layout.spec.tabs[0]?.items[0]).toMatchObject({
+ x: 0,
+ y: 0,
+ width: 6,
+ height: 4,
+ });
+ expect(layout.spec.tabs[1]?.name).toBe('Tab B');
+ });
+
+ it('handles mixed Grid and Tab groups in order', () => {
+ const panelGroups: Record = {
+ 1: {
+ id: 1,
+ layoutKind: 'Grid',
+ isCollapsed: false,
+ itemLayouts: [{ i: 'l1', x: 0, y: 0, w: 12, h: 6 }],
+ itemPanelKeys: { l1: 'panel-a' },
+ },
+ 2: {
+ id: 2,
+ layoutKind: 'Tabs',
+ isCollapsed: false,
+ defaultTab: 0,
+ activeTab: 0,
+ tabs: [
+ {
+ name: 'Tab 1',
+ itemLayouts: [{ i: 'tl1', x: 0, y: 0, w: 12, h: 6 }],
+ itemPanelKeys: { tl1: 'panel-b' },
+ },
+ ],
+ },
+ };
+ const order = [1, 2];
+
+ const result = convertPanelGroupsToLayouts(panelGroups, order);
+ expect(result).toHaveLength(2);
+ expect(result[0]?.kind).toBe('Grid');
+ expect(result[1]?.kind).toBe('Tabs');
+ });
+});
diff --git a/dashboards/src/context/useDashboard.tsx b/dashboards/src/context/useDashboard.tsx
index 7a02e8d0..c1984ddc 100644
--- a/dashboards/src/context/useDashboard.tsx
+++ b/dashboards/src/context/useDashboard.tsx
@@ -11,7 +11,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { createPanelRef, DashboardSpec, DurationString, GridDefinition, PanelGroupId } from '@perses-dev/spec';
+import {
+ createPanelRef,
+ DashboardSpec,
+ DurationString,
+ GridDefinition,
+ LayoutDefinition,
+ PanelGroupId,
+ TabDefinition,
+} from '@perses-dev/spec';
import { DashboardResource, PanelGroupDefinition } from '../model';
import { useDashboardStore } from './DashboardProvider';
@@ -111,17 +119,17 @@ export function useDashboard(): {
};
}
-function convertPanelGroupsToLayouts(
+export function convertPanelGroupsToLayouts(
panelGroups: Record,
panelGroupOrder: PanelGroupId[]
-): GridDefinition[] {
- const layouts: GridDefinition[] = [];
- panelGroupOrder.map((groupOrderId) => {
+): LayoutDefinition[] {
+ const layouts: LayoutDefinition[] = [];
+ panelGroupOrder.forEach((groupOrderId) => {
const group = panelGroups[groupOrderId];
if (group === undefined) {
throw new Error('panel group not found');
}
- const { title, isCollapsed, repeatVariable, itemLayouts, itemPanelKeys } = group;
+ const { title, isCollapsed } = group;
let display = undefined;
if (title || isCollapsed !== undefined) {
display = {
@@ -131,27 +139,68 @@ function convertPanelGroupsToLayouts(
},
};
}
- const layout: GridDefinition = {
- kind: 'Grid',
- spec: {
- display,
- items: itemLayouts.map((layout) => {
- const panelKey = itemPanelKeys[layout.i];
- if (panelKey === undefined) {
- throw new Error(`Missing panel key of layout ${layout.i}`);
- }
- return {
- x: layout.x,
- y: layout.y,
- width: layout.w,
- height: layout.h,
- content: createPanelRef(panelKey),
- };
- }),
- repeatVariable: repeatVariable,
- },
- };
- layouts.push(layout);
+
+ switch (group.layoutKind) {
+ case 'Grid': {
+ const { repeatVariable, itemLayouts, itemPanelKeys } = group;
+ const layout: GridDefinition = {
+ kind: 'Grid',
+ spec: {
+ display,
+ items: itemLayouts.map((layout) => {
+ const panelKey = itemPanelKeys[layout.i];
+ if (panelKey === undefined) {
+ throw new Error(`Missing panel key of layout ${layout.i}`);
+ }
+ return {
+ x: layout.x,
+ y: layout.y,
+ width: layout.w,
+ height: layout.h,
+ content: createPanelRef(panelKey),
+ };
+ }),
+ repeatVariable: repeatVariable,
+ },
+ };
+ layouts.push(layout);
+ break;
+ }
+
+ case 'Tabs': {
+ const layout: TabDefinition = {
+ kind: 'Tabs',
+ spec: {
+ display,
+ tabs: group.tabs.map((tab) => ({
+ name: tab.name,
+ items: tab.itemLayouts.map((tabLayout) => {
+ const panelKey = tab.itemPanelKeys[tabLayout.i];
+ if (panelKey === undefined) {
+ throw new Error(`Missing panel key of tab layout ${tabLayout.i}`);
+ }
+ return {
+ x: tabLayout.x,
+ y: tabLayout.y,
+ width: tabLayout.w,
+ height: tabLayout.h,
+ content: createPanelRef(panelKey),
+ };
+ }),
+ })),
+ defaultTab: group.defaultTab,
+ },
+ };
+ layouts.push(layout);
+ break;
+ }
+
+ default: {
+ // Exhaustive check
+ const _exhaustiveCheck: never = group;
+ throw new Error(`Unknown layout kind: ${(_exhaustiveCheck as PanelGroupDefinition).layoutKind}`);
+ }
+ }
});
return layouts;
diff --git a/dashboards/src/model/PanelGroupDefinition.test.ts b/dashboards/src/model/PanelGroupDefinition.test.ts
new file mode 100644
index 00000000..bfb78e1d
--- /dev/null
+++ b/dashboards/src/model/PanelGroupDefinition.test.ts
@@ -0,0 +1,115 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+ GridPanelGroup,
+ TabPanelGroup,
+ getGroupItemPanelKeys,
+ getGroupItemLayouts,
+ findTabContainingItem,
+} from './PanelGroupDefinition';
+
+describe('PanelGroupDefinition helpers', () => {
+ const gridGroup: GridPanelGroup = {
+ id: 1,
+ layoutKind: 'Grid',
+ isCollapsed: false,
+ title: 'Grid Group',
+ itemLayouts: [
+ { i: 'layout-1', x: 0, y: 0, w: 12, h: 6 },
+ { i: 'layout-2', x: 0, y: 6, w: 6, h: 4 },
+ ],
+ itemPanelKeys: {
+ 'layout-1': 'panel-a',
+ 'layout-2': 'panel-b',
+ },
+ };
+
+ const tabGroup: TabPanelGroup = {
+ id: 2,
+ layoutKind: 'Tabs',
+ isCollapsed: false,
+ title: 'Tab Group',
+ defaultTab: 0,
+ activeTab: 0,
+ tabs: [
+ {
+ name: 'Tab 1',
+ itemLayouts: [{ i: 'tab1-layout-1', x: 0, y: 0, w: 12, h: 6 }],
+ itemPanelKeys: { 'tab1-layout-1': 'panel-x' },
+ },
+ {
+ name: 'Tab 2',
+ itemLayouts: [
+ { i: 'tab2-layout-1', x: 0, y: 0, w: 6, h: 4 },
+ { i: 'tab2-layout-2', x: 6, y: 0, w: 6, h: 4 },
+ ],
+ itemPanelKeys: {
+ 'tab2-layout-1': 'panel-y',
+ 'tab2-layout-2': 'panel-z',
+ },
+ },
+ ],
+ };
+
+ describe('getGroupItemPanelKeys', () => {
+ it('returns itemPanelKeys directly for Grid groups', () => {
+ const result = getGroupItemPanelKeys(gridGroup);
+ expect(result).toEqual({
+ 'layout-1': 'panel-a',
+ 'layout-2': 'panel-b',
+ });
+ });
+
+ it('returns flattened panel keys across all tabs for Tab groups', () => {
+ const result = getGroupItemPanelKeys(tabGroup);
+ expect(result).toEqual({
+ 'tab1-layout-1': 'panel-x',
+ 'tab2-layout-1': 'panel-y',
+ 'tab2-layout-2': 'panel-z',
+ });
+ });
+ });
+
+ describe('getGroupItemLayouts', () => {
+ it('returns itemLayouts directly for Grid groups', () => {
+ const result = getGroupItemLayouts(gridGroup);
+ expect(result).toEqual([
+ { i: 'layout-1', x: 0, y: 0, w: 12, h: 6 },
+ { i: 'layout-2', x: 0, y: 6, w: 6, h: 4 },
+ ]);
+ });
+
+ it('returns flattened layouts across all tabs for Tab groups', () => {
+ const result = getGroupItemLayouts(tabGroup);
+ expect(result).toEqual([
+ { i: 'tab1-layout-1', x: 0, y: 0, w: 12, h: 6 },
+ { i: 'tab2-layout-1', x: 0, y: 0, w: 6, h: 4 },
+ { i: 'tab2-layout-2', x: 6, y: 0, w: 6, h: 4 },
+ ]);
+ });
+ });
+
+ describe('findTabContainingItem', () => {
+ it('returns the tab containing the given layout id', () => {
+ const result = findTabContainingItem(tabGroup, 'tab2-layout-1');
+ expect(result).toBeDefined();
+ expect(result?.name).toBe('Tab 2');
+ });
+
+ it('returns undefined when the layout id is not found in any tab', () => {
+ const result = findTabContainingItem(tabGroup, 'nonexistent');
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/dashboards/src/model/PanelGroupDefinition.ts b/dashboards/src/model/PanelGroupDefinition.ts
index 82adffe7..9819114f 100644
--- a/dashboards/src/model/PanelGroupDefinition.ts
+++ b/dashboards/src/model/PanelGroupDefinition.ts
@@ -59,21 +59,78 @@ export interface PanelGroupItemLayout extends BaseLayout {
}
/**
- * Definition of a panel group, containing layout and panel information.
+ * State for a single tab within a TabPanelGroup.
+ */
+export interface TabState {
+ name: string;
+ itemLayouts: PanelGroupItemLayout[];
+ itemPanelKeys: Record;
+}
+
+/**
+ * Base properties shared by all panel group types.
*/
-export interface PanelGroupDefinition {
+interface PanelGroupBase {
id: PanelGroupId;
isCollapsed: boolean;
title?: string;
- repeatedOriginId?: PanelGroupId; // ID of the original panel group from which this repeated group is derived
- repeatVariable?: string; // Optional, used for repeated panel groups
+ repeatedOriginId?: PanelGroupId;
+}
+
+/**
+ * A panel group that uses a grid layout for its items.
+ */
+export interface GridPanelGroup extends PanelGroupBase {
+ layoutKind: 'Grid';
itemLayouts: PanelGroupItemLayout[];
itemPanelKeys: Record;
+ repeatVariable?: string;
+}
+
+/**
+ * A panel group that uses a tabbed layout for its items.
+ */
+export interface TabPanelGroup extends PanelGroupBase {
+ layoutKind: 'Tabs';
+ tabs: TabState[];
+ defaultTab: number;
+ activeTab: number;
}
+/**
+ * Definition of a panel group, containing layout and panel information.
+ * Discriminated union on the `layoutKind` field.
+ */
+export type PanelGroupDefinition = GridPanelGroup | TabPanelGroup;
+
/**
* Check if two PanelGroupItemId are equal
*/
export function isPanelGroupItemIdEqual(a?: PanelGroupItemId, b?: PanelGroupItemId): boolean {
return a?.panelGroupId === b?.panelGroupId && a?.panelGroupItemLayoutId === b?.panelGroupItemLayoutId;
}
+
+/**
+ * Returns a unified record of all item panel keys across all tabs (for TabPanelGroup)
+ * or the direct itemPanelKeys (for GridPanelGroup).
+ */
+export function getGroupItemPanelKeys(group: PanelGroupDefinition): Record {
+ if (group.layoutKind === 'Grid') return group.itemPanelKeys;
+ return Object.assign({}, ...group.tabs.map((tab) => tab.itemPanelKeys));
+}
+
+/**
+ * Returns a unified array of all item layouts across all tabs (for TabPanelGroup)
+ * or the direct itemLayouts (for GridPanelGroup).
+ */
+export function getGroupItemLayouts(group: PanelGroupDefinition): PanelGroupItemLayout[] {
+ if (group.layoutKind === 'Grid') return group.itemLayouts;
+ return group.tabs.flatMap((tab) => tab.itemLayouts);
+}
+
+/**
+ * For a TabPanelGroup, find the tab that contains a given layout item ID.
+ */
+export function findTabContainingItem(group: TabPanelGroup, layoutId: PanelGroupItemLayoutId): TabState | undefined {
+ return group.tabs.find((tab) => tab.itemPanelKeys[layoutId] !== undefined);
+}
diff --git a/dashboards/src/utils/panelUtils.ts b/dashboards/src/utils/panelUtils.ts
index ca336e6c..2cf50179 100644
--- a/dashboards/src/utils/panelUtils.ts
+++ b/dashboards/src/utils/panelUtils.ts
@@ -12,10 +12,10 @@
// limitations under the License.
import { GRID_LAYOUT_SMALL_BREAKPOINT, GRID_LAYOUT_COLS } from '../constants';
-import { PanelGroupDefinition, PanelGroupItemLayout } from '../model';
+import { PanelGroupItemLayout } from '../model';
-// Given a PanelGroup, will find the Y coordinate for adding a new row to the grid, taking into account the items present
-export function getYForNewRow(group: PanelGroupDefinition): number {
+// Given a group or tab with itemLayouts, will find the Y coordinate for adding a new row to the grid
+export function getYForNewRow(group: { itemLayouts: PanelGroupItemLayout[] }): number {
let newRowY = 0;
for (const layout of group.itemLayouts) {
const itemMaxY = layout.y + layout.h;
diff --git a/package-lock.json b/package-lock.json
index 34c8ef15..91fd306d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9273,9 +9273,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",