From b334b9abc4dce167f9f2389cdc56964db7507a48 Mon Sep 17 00:00:00 2001 From: Gabriel Bernal Date: Thu, 14 May 2026 18:45:16 +0200 Subject: [PATCH] [FEATURE] add tabs layout for dashboards Signed-off-by: Gabriel Bernal --- .../src/components/Dashboard/Dashboard.tsx | 21 +- .../src/components/GridLayout/GridLayout.tsx | 11 +- dashboards/src/components/GridLayout/Row.tsx | 27 +- .../PanelGroupDialog/PanelGroupEditorForm.tsx | 58 +- .../src/components/TabLayout/TabBar.tsx | 201 ++++++ .../components/TabLayout/TabEditorDialog.tsx | 84 +++ .../src/components/TabLayout/TabLayout.tsx | 214 ++++++ dashboards/src/components/TabLayout/index.ts | 16 + dashboards/src/components/index.ts | 1 + .../src/constants/grid-layout-config.ts | 10 + .../dashboard-provider-api.ts | 53 +- .../delete-panel-group-slice.ts | 5 +- .../DashboardProvider/delete-panel-slice.ts | 32 +- .../duplicate-panel-slice.ts | 22 +- .../DashboardProvider/panel-editor-slice.ts | 116 +++- .../panel-group-editor-slice.ts | 85 ++- .../panel-group-slice.test.ts | 628 ++++++++++++++++++ .../DashboardProvider/panel-group-slice.ts | 235 ++++++- .../DashboardProvider/view-panel-slice.ts | 6 +- dashboards/src/context/useDashboard.test.ts | 128 ++++ dashboards/src/context/useDashboard.tsx | 103 ++- .../src/model/PanelGroupDefinition.test.ts | 115 ++++ dashboards/src/model/PanelGroupDefinition.ts | 65 +- dashboards/src/utils/panelUtils.ts | 6 +- package-lock.json | 6 +- 25 files changed, 2075 insertions(+), 173 deletions(-) create mode 100644 dashboards/src/components/TabLayout/TabBar.tsx create mode 100644 dashboards/src/components/TabLayout/TabEditorDialog.tsx create mode 100644 dashboards/src/components/TabLayout/TabLayout.tsx create mode 100644 dashboards/src/components/TabLayout/index.ts create mode 100644 dashboards/src/context/DashboardProvider/panel-group-slice.test.ts create mode 100644 dashboards/src/context/useDashboard.test.ts create mode 100644 dashboards/src/model/PanelGroupDefinition.test.ts 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 (
+ + setLayoutKind(e.target.value as 'Grid' | 'Tabs')} + data-testid="panel-group-editor-layout-kind" + > + Grid + Tabs + + Open Closed - - setRepeatVariable(e.target.value === '' ? undefined : e.target.value)} - > - - None - - {variables - ?.sort((a, b) => a.localeCompare(b)) - .map((variable) => ( - - {variable} - - ))} - - + {layoutKind === 'Grid' && ( + + setRepeatVariable(e.target.value === '' ? undefined : e.target.value)} + > + + None + + {variables + ?.sort((a, b) => a.localeCompare(b)) + .map((variable) => ( + + {variable} + + ))} + + + )}
); 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 ( + + Edit Tab + +
+ + setName(e.target.value)} + data-testid="tab-editor-name" + /> + + + setDefaultTab(e.target.checked)} + data-testid="tab-editor-default" + /> + } + label="Set as default tab" + /> + +
+
+ + Apply + Cancel + +
+ ); +} 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",