From 60c5ff475cca27fd168c6712bbb06cbd6b17eaf8 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:39 -0700 Subject: [PATCH 1/6] feat: Extend DashboardContainer schema with tabs and display options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional fields to DashboardContainerSchema: - tabs: array of {id, title} for tabbed groups (tab bar at 2+) - activeTabId: persisted active tab selection - collapsible: whether the group can be collapsed (default true) - bordered: whether to show a border (default true) Remove the dead `type` discriminator field ('section' | 'group') — containers are now defined by their properties. Old dashboards with `type: 'section'` still parse (Zod strips unknown keys). The field can be re-added as a discriminated union if semantically different container kinds are ever needed. Also add `tabId` to TileSchema so tiles can reference a specific tab. --- .changeset/unified-group-containers.md | 6 ++++++ packages/common-utils/src/types.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/unified-group-containers.md diff --git a/.changeset/unified-group-containers.md b/.changeset/unified-group-containers.md new file mode 100644 index 0000000000..238949ad64 --- /dev/null +++ b/.changeset/unified-group-containers.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/app": patch +"@hyperdx/common-utils": patch +--- + +refactor: Unify section/group into single Group with collapsible/bordered options diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 42d568d3c7..5a1db15094 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -688,6 +688,8 @@ export const TileSchema = z.object({ h: z.number(), config: SavedChartConfigSchema, containerId: z.string().optional(), + // For tiles inside a tab container: which tab this tile belongs to + tabId: z.string().optional(), }); export const TileTemplateSchema = TileSchema.extend({ @@ -699,11 +701,23 @@ export const TileTemplateSchema = TileSchema.extend({ export type Tile = z.infer; +export const DashboardContainerTabSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), +}); + export const DashboardContainerSchema = z.object({ id: z.string().min(1), - type: z.enum(['section']), title: z.string().min(1), collapsed: z.boolean(), + // Whether the group can be collapsed (default true) + collapsible: z.boolean().optional(), + // Whether to show a border around the group (default true) + bordered: z.boolean().optional(), + // Optional tabs: 2+ entries → tab bar renders, 0-1 → plain group header. + // Tiles reference a specific tab via tabId. + tabs: z.array(DashboardContainerTabSchema).optional(), + activeTabId: z.string().optional(), }); export type DashboardContainer = z.infer; From fe51f787f05dcbe36e044b6994bc19853f5fb71a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:46 -0700 Subject: [PATCH 2/6] feat: Add @dnd-kit infrastructure for container reordering - DashboardDndContext: wraps dashboard in DndContext + SortableContext for container drag-and-drop reordering - DashboardDndComponents: SortableContainerWrapper (sortable item with drag handle props), EmptyContainerPlaceholder (shown when a container or tab has no tiles, with an Add button) - DragHandleProps type exported for GroupContainer integration --- .../src/components/DashboardDndComponents.tsx | 100 +++++++++++++++ .../src/components/DashboardDndContext.tsx | 116 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 packages/app/src/components/DashboardDndComponents.tsx create mode 100644 packages/app/src/components/DashboardDndContext.tsx diff --git a/packages/app/src/components/DashboardDndComponents.tsx b/packages/app/src/components/DashboardDndComponents.tsx new file mode 100644 index 0000000000..f890163b31 --- /dev/null +++ b/packages/app/src/components/DashboardDndComponents.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Box, Button } from '@mantine/core'; +import { IconPlus } from '@tabler/icons-react'; + +import { type DragData, type DragHandleProps } from './DashboardDndContext'; + +// --- Empty container placeholder --- +// Visual placeholder for empty groups/tabs with optional add-tile click. + +export function EmptyContainerPlaceholder({ + containerId, + children, + isEmpty, + onAddTile, +}: { + containerId: string; + children?: React.ReactNode; + isEmpty?: boolean; + onAddTile?: () => void; +}) { + return ( +
+ {isEmpty && ( + + + + )} + {children} +
+ ); +} + +// --- Sortable container wrapper (for container reordering) --- + +export function SortableContainerWrapper({ + containerId, + containerTitle, + children, +}: { + containerId: string; + containerTitle: string; + children: (dragHandleProps: DragHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: `container-sort-${containerId}`, + data: { + type: 'container', + containerId, + containerTitle, + } satisfies DragData, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {children({ ...attributes, ...listeners })} +
+ ); +} diff --git a/packages/app/src/components/DashboardDndContext.tsx b/packages/app/src/components/DashboardDndContext.tsx new file mode 100644 index 0000000000..26145d0168 --- /dev/null +++ b/packages/app/src/components/DashboardDndContext.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { Box, Text } from '@mantine/core'; + +// --- Types --- + +export type DragHandleProps = React.HTMLAttributes; + +export type DragData = { + type: 'container'; + containerId: string; + containerTitle: string; +}; + +type Props = { + children: React.ReactNode; + containers: DashboardContainer[]; + onReorderContainers: (fromIndex: number, toIndex: number) => void; +}; + +// --- Provider (container reorder only) --- + +export function DashboardDndProvider({ + children, + containers, + onReorderContainers, +}: Props) { + const [activeDrag, setActiveDrag] = useState(null); + + const mouseSensor = useSensor(MouseSensor, { + activationConstraint: { distance: 8 }, + }); + const touchSensor = useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }); + const sensors = useSensors(mouseSensor, touchSensor); + + const containerSortableIds = useMemo( + () => containers.map(c => `container-sort-${c.id}`), + [containers], + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDrag((event.active.data.current as DragData) ?? null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveDrag(null); + if (!over) return; + + const activeData = active.data.current as DragData | undefined; + if (!activeData) return; + + // Container reorder via sortable + const overData = over.data.current as DragData | undefined; + if ( + overData?.type === 'container' && + activeData.containerId !== overData.containerId + ) { + const from = containers.findIndex(c => c.id === activeData.containerId); + const to = containers.findIndex(c => c.id === overData.containerId); + if (from !== -1 && to !== -1) onReorderContainers(from, to); + } + }, + [containers, onReorderContainers], + ); + + return ( + + + {children} + + + {activeDrag && ( + + + {activeDrag.containerTitle} + + + )} + + + ); +} From 131a3c1ba1c2d81fcb6351ff770375d820ecef53 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:57 -0700 Subject: [PATCH 3/6] feat: Add GroupContainer, replace SectionHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unified component for all dashboard container types. Features: - Collapse chevron with keyboard/screen reader support (role, tabIndex, aria-expanded, aria-label, Enter/Space handler) - Optional border toggle - Tab bar (appears at 2+ tabs) with inline rename, delete confirmation - Collapsed state shows pipe-separated tab names (max 4, then "...") - Alert indicators: red dot on tabs/header for active alerts - Overflow menu: Add Tab, Collapse by Default, Disable/Enable Collapse, Hide/Show Border, Delete Group (divider guarded) - Hidden controls removed from keyboard tab order (tabIndex toggle) - Fixed header height prevents UI jump on collapse/expand Deletes SectionHeader.tsx and its tests — all functionality merged into GroupContainer. --- .../app/src/components/GroupContainer.tsx | 526 ++++++++++++++++++ packages/app/src/components/SectionHeader.tsx | 216 ------- .../__tests__/SectionHeader.test.tsx | 152 ----- 3 files changed, 526 insertions(+), 368 deletions(-) create mode 100644 packages/app/src/components/GroupContainer.tsx delete mode 100644 packages/app/src/components/SectionHeader.tsx delete mode 100644 packages/app/src/components/__tests__/SectionHeader.test.tsx diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx new file mode 100644 index 0000000000..be62884acd --- /dev/null +++ b/packages/app/src/components/GroupContainer.tsx @@ -0,0 +1,526 @@ +import { useState } from 'react'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { ActionIcon, Flex, Menu, Tabs, Text, Tooltip } from '@mantine/core'; +import { + IconChevronRight, + IconDotsVertical, + IconGripVertical, + IconPencil, + IconPlus, + IconTrash, +} from '@tabler/icons-react'; + +import { type DragHandleProps } from '@/components/DashboardDndContext'; + +function AlertDot({ size = 6 }: { size?: number }) { + return ( + + ); +} + +type GroupContainerProps = { + container: DashboardContainer; + collapsed: boolean; + defaultCollapsed: boolean; + onToggle: () => void; + onToggleDefaultCollapsed?: () => void; + onToggleCollapsible?: () => void; + onToggleBordered?: () => void; + onDelete?: () => void; + onAddTile?: () => void; + activeTabId?: string; + onTabChange?: (tabId: string) => void; + onAddTab?: () => void; + onRenameTab?: (tabId: string, newTitle: string) => void; + onDeleteTab?: (tabId: string) => void; + onRename?: (newTitle: string) => void; + children: (activeTabId: string | undefined) => React.ReactNode; + dragHandleProps?: DragHandleProps; + confirm?: ( + message: React.ReactNode, + confirmLabel?: string, + options?: { variant?: 'primary' | 'danger' }, + ) => Promise; + /** Tab IDs that contain tiles with active alerts */ + alertingTabIds?: Set; +}; + +export default function GroupContainer({ + container, + collapsed, + defaultCollapsed, + onToggle, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDelete, + onAddTile, + activeTabId, + onTabChange, + onAddTab, + onRenameTab, + onDeleteTab, + onRename, + children, + dragHandleProps, + confirm, + alertingTabIds, +}: GroupContainerProps) { + const [editing, setEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(container.title); + const [hovered, setHovered] = useState(false); + const [editingTabId, setEditingTabId] = useState(null); + const [editedTabTitle, setEditedTabTitle] = useState(''); + const [hoveredTabId, setHoveredTabId] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + + const tabs = container.tabs ?? []; + const hasTabs = tabs.length >= 2; + const collapsible = container.collapsible !== false; + const bordered = container.bordered !== false; + const showControls = hovered || menuOpen; + const resolvedActiveTabId = activeTabId ?? tabs[0]?.id; + const isCollapsed = collapsible && collapsed; + + const firstTab = tabs[0]; + const headerTitle = firstTab?.title ?? container.title; + + const handleSaveRename = () => { + const trimmed = editedTitle.trim(); + if (trimmed && trimmed !== headerTitle) { + if (firstTab) { + onRenameTab?.(firstTab.id, trimmed); + } else { + onRename?.(trimmed); + } + } else { + setEditedTitle(headerTitle); + } + setEditing(false); + }; + + const handleSaveTabRename = (tabId: string) => { + const trimmed = editedTabTitle.trim(); + const tab = tabs.find(t => t.id === tabId); + if (trimmed && tab && trimmed !== tab.title) { + onRenameTab?.(tabId, trimmed); + } + setEditingTabId(null); + }; + + const handleDeleteTab = async (tabId: string) => { + if (confirm) { + const tab = tabs.find(t => t.id === tabId); + const confirmed = await confirm( + <> + Delete tab{' '} + + {tab?.title ?? 'this tab'} + + ? Tiles will be moved to the first remaining tab. + , + 'Delete', + { variant: 'danger' }, + ); + if (!confirmed) return; + } + onDeleteTab?.(tabId); + }; + + const chevron = collapsible ? ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle?.(); + } + }} + data-testid={`group-chevron-${container.id}`} + /> + ) : null; + + // Single "Add Tile" button (1 click) shown on hover, plus "Add Tab" in overflow + const addTileButton = !isCollapsed && onAddTile && ( + + + + + + ); + + const overflowMenu = ( + + + + + + + + {onAddTab && ( + } onClick={onAddTab}> + Add Tab + + )} + {(onToggleCollapsible || + onToggleBordered || + onToggleDefaultCollapsed) && + onAddTab && } + {onToggleCollapsible && ( + + {collapsible ? 'Disable Collapse' : 'Enable Collapse'} + + )} + {collapsible && onToggleDefaultCollapsed && ( + + {defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'} + + )} + {onToggleBordered && ( + + {bordered ? 'Hide Border' : 'Show Border'} + + )} + {onDelete && ( + <> + {(onAddTab || + onToggleCollapsible || + onToggleBordered || + onToggleDefaultCollapsed) && } + } + color="red" + onClick={onDelete} + > + Delete Group + + + )} + + + ); + + const dragHandle = dragHandleProps && ( +
+ +
+ ); + + // Collapsed header: pipe-separated tab names (max 4, then "…") + const MAX_COLLAPSED_TABS = 4; + const collapsedTabLabel = + isCollapsed && hasTabs + ? tabs + .slice(0, MAX_COLLAPSED_TABS) + .map(t => t.title) + .join(' | ') + (tabs.length > MAX_COLLAPSED_TABS ? ' | …' : '') + : null; + + // Tab IDs with active alerts (for indicators) + const hasContainerAlert = alertingTabIds != null && alertingTabIds.size > 0; + + // Fixed header height to prevent jump on collapse/expand + const headerHeight = 36; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + border: bordered + ? '1px solid var(--mantine-color-default-border)' + : undefined, + borderRadius: bordered ? 4 : undefined, + marginTop: 8, + }} + > + {hasTabs && !isCollapsed ? ( + /* Tab bar header (2+ tabs, expanded) */ + val && onTabChange?.(val)} + > + + {dragHandle} + {chevron} + + {tabs.map(tab => ( + setHoveredTabId(tab.id)} + onMouseLeave={() => setHoveredTabId(null)} + rightSection={ + onDeleteTab && tabs.length > 1 ? ( + { + e.stopPropagation(); + handleDeleteTab(tab.id); + }} + title="Delete tab" + data-testid={`tab-delete-${tab.id}`} + > + + + ) : undefined + } + onDoubleClick={ + onRenameTab + ? () => { + setEditingTabId(tab.id); + setEditedTabTitle(tab.title); + } + : undefined + } + > + {editingTabId === tab.id ? ( +
{ + e.preventDefault(); + handleSaveTabRename(tab.id); + }} + onClick={e => e.stopPropagation()} + style={{ display: 'inline' }} + > + setEditedTabTitle(e.target.value)} + onBlur={() => handleSaveTabRename(tab.id)} + onKeyDown={e => { + e.stopPropagation(); + if (e.key === 'Escape') setEditingTabId(null); + }} + autoFocus + style={{ + border: 'none', + outline: 'none', + background: 'transparent', + font: 'inherit', + color: 'inherit', + padding: 0, + margin: 0, + width: `${Math.max(editedTabTitle.length, 3)}ch`, + }} + data-testid={`tab-rename-input-${tab.id}`} + /> +
+ ) : ( + + {tab.title} + {alertingTabIds?.has(tab.id) && } + + )} +
+ ))} +
+ {/* Rename active tab button */} + {onRenameTab && resolvedActiveTabId && ( + + { + const tab = tabs.find(t => t.id === resolvedActiveTabId); + if (tab) { + setEditingTabId(tab.id); + setEditedTabTitle(tab.title); + } + }} + data-testid={`tab-rename-btn-${container.id}`} + > + + + + )} + {addTileButton} + {overflowMenu} +
+
+ ) : ( + /* Plain header (1 tab or collapsed) — shows title + chevron */ + + {dragHandle} + {chevron} + {editing ? ( +
{ + e.preventDefault(); + handleSaveRename(); + }} + style={{ flex: 1 }} + > + setEditedTitle(e.target.value)} + onBlur={handleSaveRename} + onKeyDown={e => { + e.stopPropagation(); + if (e.key === 'Escape') { + setEditedTitle(headerTitle); + setEditing(false); + } + }} + autoFocus + style={{ + border: 'none', + outline: 'none', + background: 'transparent', + font: 'inherit', + fontSize: 'var(--mantine-font-size-sm)', + fontWeight: 500, + color: 'inherit', + padding: 0, + margin: 0, + width: '100%', + }} + data-testid={`group-rename-input-${container.id}`} + /> +
+ ) : ( + + { + e.stopPropagation(); + setEditedTitle(headerTitle); + setEditing(true); + } + : undefined + } + > + {collapsedTabLabel ?? headerTitle} + + {isCollapsed && hasContainerAlert && } + + )} + {addTileButton} + {overflowMenu} +
+ )} + {!isCollapsed && ( +
+ {children(hasTabs ? resolvedActiveTabId : undefined)} +
+ )} +
+ ); +} diff --git a/packages/app/src/components/SectionHeader.tsx b/packages/app/src/components/SectionHeader.tsx deleted file mode 100644 index db0e11904c..0000000000 --- a/packages/app/src/components/SectionHeader.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useState } from 'react'; -import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; -import { ActionIcon, Flex, Input, Menu, Text } from '@mantine/core'; -import { - IconChevronRight, - IconDotsVertical, - IconEye, - IconEyeOff, - IconPlus, - IconTrash, -} from '@tabler/icons-react'; - -export default function SectionHeader({ - section, - tileCount, - collapsed, - defaultCollapsed, - onToggle, - onToggleDefaultCollapsed, - onRename, - onDelete, - onAddTile, -}: { - section: DashboardContainer; - tileCount: number; - /** Effective collapsed state (URL state ?? DB default). */ - collapsed: boolean; - /** The DB-stored default collapsed state. */ - defaultCollapsed: boolean; - /** Toggle collapse in URL state (chevron click). */ - onToggle: () => void; - /** Toggle the DB-stored default collapsed state (menu action). */ - onToggleDefaultCollapsed?: () => void; - onRename?: (newTitle: string) => void; - onDelete?: () => void; - onAddTile?: () => void; -}) { - const [editing, setEditing] = useState(false); - const [editedTitle, setEditedTitle] = useState(section.title); - const [hovered, setHovered] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - - const showControls = hovered || menuOpen; - const hasMenuControls = onDelete != null || onToggleDefaultCollapsed != null; - - const handleSaveRename = () => { - const trimmed = editedTitle.trim(); - if (trimmed && trimmed !== section.title) { - onRename?.(trimmed); - } else { - setEditedTitle(section.title); - } - setEditing(false); - }; - - const handleTitleClick = (e: React.MouseEvent) => { - if (!onRename) return; - e.stopPropagation(); - setEditedTitle(section.title); - setEditing(true); - }; - - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - borderBottom: '1px solid var(--mantine-color-dark-4)', - userSelect: 'none', - }} - data-testid={`section-header-${section.id}`} - > - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(); - } - } - } - role="button" - tabIndex={editing ? undefined : 0} - aria-expanded={!collapsed} - aria-label={`Toggle ${section.title} section`} - > - - {editing ? ( -
{ - e.preventDefault(); - handleSaveRename(); - }} - onClick={e => e.stopPropagation()} - > - setEditedTitle(e.currentTarget.value)} - onBlur={handleSaveRename} - onKeyDown={e => { - if (e.key === 'Escape') { - setEditedTitle(section.title); - setEditing(false); - } - }} - autoFocus - data-testid={`section-rename-input-${section.id}`} - /> -
- ) : ( - <> - - {section.title} - - {collapsed && tileCount > 0 && ( - - ({tileCount} {tileCount === 1 ? 'tile' : 'tiles'}) - - )} - - )} -
- {onAddTile && !editing && ( - { - e.stopPropagation(); - onAddTile(); - }} - title="Add tile to section" - data-testid={`section-add-tile-${section.id}`} - style={{ - opacity: showControls ? 1 : 0, - pointerEvents: showControls ? 'auto' : 'none', - }} - > - - - )} - {hasMenuControls && !editing && ( - - - e.stopPropagation()} - data-testid={`section-menu-${section.id}`} - style={{ - opacity: showControls ? 1 : 0, - pointerEvents: showControls ? 'auto' : 'none', - }} - > - - - - - {onToggleDefaultCollapsed && ( - - ) : ( - - ) - } - onClick={onToggleDefaultCollapsed} - data-testid={`section-toggle-default-${section.id}`} - > - {defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'} - - )} - {onDelete && ( - <> - - } - color="red" - onClick={onDelete} - data-testid={`section-delete-${section.id}`} - > - Delete Section - - - )} - - - )} -
- ); -} diff --git a/packages/app/src/components/__tests__/SectionHeader.test.tsx b/packages/app/src/components/__tests__/SectionHeader.test.tsx deleted file mode 100644 index 54d5a4a9b0..0000000000 --- a/packages/app/src/components/__tests__/SectionHeader.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React from 'react'; -import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import SectionHeader from '../SectionHeader'; - -// Menu buttons have pointer-events:none when not hovered; skip that check. -const user = userEvent.setup({ pointerEventsCheck: 0 }); - -const makeSection = ( - overrides: Partial = {}, -): DashboardContainer => ({ - id: 'section-1', - type: 'section', - title: 'My Section', - collapsed: false, - ...overrides, -}); - -describe('SectionHeader', () => { - it('renders section title and tile count when collapsed', () => { - renderWithMantine( - , - ); - - expect(screen.getByText('My Section')).toBeInTheDocument(); - expect(screen.getByText('(3 tiles)')).toBeInTheDocument(); - }); - - it('does not show tile count when expanded', () => { - renderWithMantine( - , - ); - - expect(screen.getByText('My Section')).toBeInTheDocument(); - expect(screen.queryByText('(3 tiles)')).not.toBeInTheDocument(); - }); - - it('calls onToggle (URL state) when chevron area is clicked', async () => { - const onToggle = jest.fn(); - const onToggleDefaultCollapsed = jest.fn(); - - renderWithMantine( - , - ); - - await user.click( - screen.getByRole('button', { name: /Toggle My Section section/i }), - ); - - expect(onToggle).toHaveBeenCalledTimes(1); - expect(onToggleDefaultCollapsed).not.toHaveBeenCalled(); - }); - - it('shows "Collapse by Default" when DB default is expanded', async () => { - renderWithMantine( - , - ); - - // Open the menu - await user.click(screen.getByTestId('section-menu-section-1')); - expect(await screen.findByText('Collapse by Default')).toBeInTheDocument(); - }); - - it('shows "Expand by Default" when DB default is collapsed', async () => { - renderWithMantine( - , - ); - - await user.click(screen.getByTestId('section-menu-section-1')); - expect(await screen.findByText('Expand by Default')).toBeInTheDocument(); - }); - - it('calls onToggleDefaultCollapsed (DB state) from menu item', async () => { - const onToggle = jest.fn(); - const onToggleDefaultCollapsed = jest.fn(); - - renderWithMantine( - , - ); - - await user.click(screen.getByTestId('section-menu-section-1')); - await user.click(await screen.findByText('Collapse by Default')); - - expect(onToggleDefaultCollapsed).toHaveBeenCalledTimes(1); - expect(onToggle).not.toHaveBeenCalled(); - }); - - it('uses collapsed prop for visual state independent of section.collapsed', () => { - // section.collapsed is false (DB default), but collapsed prop is true (URL override) - renderWithMantine( - , - ); - - // Should show tile count because collapsed=true (URL state takes precedence) - expect(screen.getByText('(5 tiles)')).toBeInTheDocument(); - // The aria-expanded should reflect the effective state - expect( - screen.getByRole('button', { name: /Toggle My Section section/i }), - ).toHaveAttribute('aria-expanded', 'false'); - }); -}); From c632c6cf1d31172ae6fcb87495fbe9307236665b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:15:04 -0700 Subject: [PATCH 4/6] feat: Add useDashboardContainers and useTileSelection hooks useDashboardContainers (301 lines): Container CRUD + tab lifecycle - Add/rename/delete containers - Toggle collapsed, collapsible, bordered - Add/rename/delete tabs with tile migration - Tab change persistence - Container.title synced with tabs[0] on rename and delete useTileSelection (74 lines): Multi-select + Cmd+G grouping - Shift+click tile selection - Cmd+G groups selected tiles into a new container - Assigns tabId to newly grouped tiles --- .../app/src/hooks/useDashboardContainers.tsx | 301 ++++++++++++++++++ packages/app/src/hooks/useTileSelection.ts | 74 +++++ 2 files changed, 375 insertions(+) create mode 100644 packages/app/src/hooks/useDashboardContainers.tsx create mode 100644 packages/app/src/hooks/useTileSelection.ts diff --git a/packages/app/src/hooks/useDashboardContainers.tsx b/packages/app/src/hooks/useDashboardContainers.tsx new file mode 100644 index 0000000000..edbc0ecab8 --- /dev/null +++ b/packages/app/src/hooks/useDashboardContainers.tsx @@ -0,0 +1,301 @@ +import { useCallback } from 'react'; +import produce from 'immer'; +import { arrayMove } from '@dnd-kit/sortable'; +import { Text } from '@mantine/core'; + +import { Dashboard } from '@/dashboard'; +import { makeId } from '@/utils/tilePositioning'; + +type ConfirmFn = ( + message: React.ReactNode, + confirmLabel?: string, + options?: { variant?: 'primary' | 'danger' }, +) => Promise; + +export default function useDashboardContainers({ + dashboard, + setDashboard, + confirm, +}: { + dashboard: Dashboard | undefined; + setDashboard: (dashboard: Dashboard) => void; + confirm: ConfirmFn; +}) { + const handleAddContainer = useCallback(() => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + if (!draft.containers) draft.containers = []; + const containerId = makeId(); + const tabId = makeId(); + draft.containers.push({ + id: containerId, + title: 'New Group', + collapsed: false, + tabs: [{ id: tabId, title: 'New Group' }], + activeTabId: tabId, + }); + }), + ); + }, [dashboard, setDashboard]); + + const handleToggleCollapsed = useCallback( + (containerId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(s => s.id === containerId); + if (container) container.collapsed = !container.collapsed; + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleRenameContainer = useCallback( + (containerId: string, newTitle: string) => { + if (!dashboard || !newTitle.trim()) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(s => s.id === containerId); + if (container) { + container.title = newTitle.trim(); + // Sync tabs[0].title when there is 1 tab (they share the header) + if (container.tabs?.length === 1) { + container.tabs[0].title = newTitle.trim(); + } + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleDeleteContainer = useCallback( + async (containerId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + const tileCount = dashboard.tiles.filter( + t => t.containerId === containerId, + ).length; + const label = container?.title ?? 'this group'; + + const message = + tileCount > 0 ? ( + <> + Delete{' '} + + {label} + + ?{' '} + {`${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`} + + ) : ( + <> + Delete{' '} + + {label} + + ? + + ); + + const confirmed = await confirm(message, 'Delete', { + variant: 'danger', + }); + if (!confirmed) return; + + setDashboard( + produce(dashboard, draft => { + const allContainerIds = new Set( + draft.containers?.map(c => c.id) ?? [], + ); + let maxUngroupedY = 0; + for (const tile of draft.tiles) { + if (!tile.containerId || !allContainerIds.has(tile.containerId)) { + maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h); + } + } + + for (const tile of draft.tiles) { + if (tile.containerId === containerId) { + tile.y += maxUngroupedY; + delete tile.containerId; + delete tile.tabId; + } + } + + draft.containers = draft.containers?.filter( + s => s.id !== containerId, + ); + }), + ); + }, + [dashboard, setDashboard, confirm], + ); + + const handleReorderContainers = useCallback( + (fromIndex: number, toIndex: number) => { + if (!dashboard?.containers) return; + setDashboard( + produce(dashboard, draft => { + if (draft.containers) { + draft.containers = arrayMove(draft.containers, fromIndex, toIndex); + } + }), + ); + }, + [dashboard, setDashboard], + ); + + // --- Tab management --- + + const handleAddTab = useCallback( + (containerId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + if (!container) return; + const existingTabs = container.tabs ?? []; + + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(c => c.id === containerId); + if (!c) return; + + if (existingTabs.length === 1) { + // Group already has 1 tab (the default); just add a second tab + const newTabId = makeId(); + if (!c.tabs) c.tabs = []; + c.tabs.push({ id: newTabId, title: 'New Tab' }); + c.activeTabId = newTabId; + // Ensure existing tiles are assigned to the first tab + const firstTabId = existingTabs[0].id; + for (const tile of draft.tiles) { + if (tile.containerId === containerId && !tile.tabId) { + tile.tabId = firstTabId; + } + } + } else if (existingTabs.length === 0) { + // Legacy container with no tabs: create 2 tabs + const tab1Id = makeId(); + const tab2Id = makeId(); + c.tabs = [ + { id: tab1Id, title: 'Tab 1' }, + { id: tab2Id, title: 'Tab 2' }, + ]; + c.activeTabId = tab1Id; + for (const tile of draft.tiles) { + if (tile.containerId === containerId) { + tile.tabId = tab1Id; + } + } + } else { + // Already has 2+ tabs, add one more + if (!c.tabs) c.tabs = []; + const newTabId = makeId(); + c.tabs.push({ + id: newTabId, + title: `Tab ${existingTabs.length + 1}`, + }); + c.activeTabId = newTabId; + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleRenameTab = useCallback( + (containerId: string, tabId: string, newTitle: string) => { + if (!dashboard || !newTitle.trim()) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(c => c.id === containerId); + const tab = container?.tabs?.find(t => t.id === tabId); + if (tab) { + tab.title = newTitle.trim(); + // Keep container.title in sync when renaming the first (or only) tab + if (container && container.tabs?.[0]?.id === tabId) { + container.title = newTitle.trim(); + } + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleDeleteTab = useCallback( + (containerId: string, tabId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + if (!container?.tabs) return; + const remaining = container.tabs.filter(t => t.id !== tabId); + + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(c => c.id === containerId); + if (!c?.tabs) return; + + if (remaining.length <= 1) { + // Keep the 1 remaining tab (don't clear tabs array) + const keepTab = remaining[0]; + c.tabs = remaining; + c.activeTabId = keepTab?.id; + // Sync container title to surviving tab + if (keepTab) c.title = keepTab.title; + // Move tiles from deleted tab to the remaining tab + for (const tile of draft.tiles) { + if (tile.containerId === containerId && tile.tabId === tabId) { + tile.tabId = keepTab?.id; + } + } + } else { + const targetTabId = remaining[0].id; + // Move tiles from deleted tab to first remaining tab + for (const tile of draft.tiles) { + if (tile.containerId === containerId && tile.tabId === tabId) { + tile.tabId = targetTabId; + } + } + c.tabs = c.tabs.filter(t => t.id !== tabId); + if (c.activeTabId === tabId) { + c.activeTabId = targetTabId; + } + // Sync container title to new first tab + if (c.tabs[0]) c.title = c.tabs[0].title; + } + }), + ); + }, + [dashboard, setDashboard], + ); + + // Intentionally persisted to server (same as collapsed state) — shared + // across all viewers. If user-local tab state is needed later, move to + // useState/localStorage instead. + const handleTabChange = useCallback( + (containerId: string, tabId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(c => c.id === containerId); + if (container) container.activeTabId = tabId; + }), + ); + }, + [dashboard, setDashboard], + ); + + return { + handleAddContainer, + handleToggleCollapsed, + handleRenameContainer, + handleDeleteContainer, + handleReorderContainers, + handleAddTab, + handleRenameTab, + handleDeleteTab, + handleTabChange, + }; +} diff --git a/packages/app/src/hooks/useTileSelection.ts b/packages/app/src/hooks/useTileSelection.ts new file mode 100644 index 0000000000..0a992c3b64 --- /dev/null +++ b/packages/app/src/hooks/useTileSelection.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react'; +import produce from 'immer'; +import { useHotkeys } from '@mantine/hooks'; + +import { Dashboard } from '@/dashboard'; +import { makeId } from '@/utils/tilePositioning'; + +export default function useTileSelection({ + dashboard, + setDashboard, +}: { + dashboard: Dashboard | undefined; + setDashboard: (dashboard: Dashboard) => void; +}) { + const [selectedTileIds, setSelectedTileIds] = useState>( + new Set(), + ); + + const handleTileSelect = useCallback((tileId: string, shiftKey: boolean) => { + if (!shiftKey) return; + setSelectedTileIds(prev => { + const next = new Set(prev); + if (next.has(tileId)) next.delete(tileId); + else next.add(tileId); + return next; + }); + }, []); + + // Creates a group container and assigns selected tiles to it. + const handleGroupSelected = useCallback(() => { + if (!dashboard || selectedTileIds.size === 0) return; + const groupId = makeId(); + const tabId = makeId(); + setDashboard( + produce(dashboard, draft => { + if (!draft.containers) draft.containers = []; + draft.containers.push({ + id: groupId, + title: 'New Group', + collapsed: false, + tabs: [{ id: tabId, title: 'New Group' }], + activeTabId: tabId, + }); + for (const tile of draft.tiles) { + if (selectedTileIds.has(tile.id)) { + tile.containerId = groupId; + tile.tabId = tabId; + } + } + }), + ); + setSelectedTileIds(new Set()); + }, [dashboard, selectedTileIds, setDashboard]); + + // Cmd+G / Ctrl+G to group selected tiles + useHotkeys([ + [ + 'mod+g', + e => { + e.preventDefault(); + handleGroupSelected(); + }, + ], + // Escape to clear selection + ['escape', () => setSelectedTileIds(new Set())], + ]); + + return { + selectedTileIds, + setSelectedTileIds, + handleTileSelect, + handleGroupSelected, + }; +} From d4fa22f974d0060123259f6a35144f0d6f4851ce Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:15:52 -0700 Subject: [PATCH 5/6] feat: Integrate groups, tabs, and DnD into dashboard page Wire GroupContainer, useDashboardContainers, useTileSelection, and DnD context into DBDashboardPage: - Add @dnd-kit dependencies (core, sortable, utilities) - Single "New Group" in Add menu (replaces Section + Group) - Container rendering with collapsible/bordered/tabbed support - Tile move dropdown with IconCornerDownRight and tab targets - URL-based collapse state (cleared on collapsible toggle) - Alert indicator computation: alertingTabIds per container from tile alert state, passed to GroupContainer - Auto-expand on collapsible disable (prevents stuck-collapsed) - Tile positioning updated for container-aware layout --- packages/app/package.json | 3 + packages/app/src/DBDashboardPage.tsx | 665 +++++++++++++++------- packages/app/src/dashboard.ts | 1 + packages/app/src/utils/tilePositioning.ts | 64 ++- yarn.lock | 52 ++ 5 files changed, 554 insertions(+), 231 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 81f067f892..b142ef3cd7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.7.0", "@dagrejs/dagre": "^1.1.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.0", "@hyperdx/browser": "^0.22.0", "@hyperdx/common-utils": "^0.16.1", diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 6714b08f0f..fa9900c355 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -62,15 +62,16 @@ import { IconBell, IconChartBar, IconCopy, + IconCornerDownRight, IconDeviceFloppy, IconDotsVertical, IconDownload, IconFilterEdit, - IconLayoutList, IconPencil, IconPlayerPlay, IconPlus, IconRefresh, + IconSquaresDiagonal, IconTags, IconTrash, IconUpload, @@ -79,12 +80,20 @@ import { } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; +import { + EmptyContainerPlaceholder, + SortableContainerWrapper, +} from '@/components/DashboardDndComponents'; +import { + DashboardDndProvider, + type DragHandleProps, +} from '@/components/DashboardDndContext'; import EditTimeChartForm from '@/components/DBEditTimeChartForm'; import DBNumberChart from '@/components/DBNumberChart'; import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; -import SectionHeader from '@/components/SectionHeader'; +import GroupContainer from '@/components/GroupContainer'; import { TimePicker } from '@/components/TimePicker'; import { Dashboard, @@ -92,6 +101,8 @@ import { useCreateDashboard, useDeleteDashboard, } from '@/dashboard'; +import useDashboardContainers from '@/hooks/useDashboardContainers'; +import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer from './components/charts/ChartContainer'; import { DBPieChart } from './components/DBPieChart'; @@ -103,6 +114,7 @@ import SearchWhereInput, { import { Tags } from './components/Tags'; import useDashboardFilters from './hooks/useDashboardFilters'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; +import useTileSelection from './hooks/useTileSelection'; import { useBrandDisplayName } from './theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils'; @@ -126,10 +138,16 @@ import { useZIndex, ZIndexContext } from './zIndex'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -const makeId = () => Math.floor(100000000 * Math.random()).toString(36); - const ReactGridLayout = WidthProvider(RGL); +type MoveTarget = { + containerId: string; + tabId?: string; + label: string; + // For tabs: all tabs in order with the target tab ID + allTabs?: { id: string; title: string }[]; +}; + const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ i: chart.id, x: chart.x, @@ -156,8 +174,8 @@ const Tile = forwardRef( onEditClick, onDeleteClick, onUpdateChart, - onMoveToSection, - containers: availableSections, + onMoveToGroup, + moveTargets, granularity, onTimeRangeSelect, filters, @@ -170,6 +188,8 @@ const Tile = forwardRef( onTouchEnd, children, isHighlighted, + isSelected, + onSelect, }: { chart: Tile; dateRange: [Date, Date]; @@ -178,8 +198,8 @@ const Tile = forwardRef( onAddAlertClick?: () => void; onDeleteClick: () => void; onUpdateChart?: (chart: Tile) => void; - onMoveToSection?: (containerId: string | undefined) => void; - containers?: DashboardContainer[]; + onMoveToGroup?: (containerId: string | undefined, tabId?: string) => void; + moveTargets?: MoveTarget[]; onSettled?: () => void; granularity: SQLInterval | undefined; onTimeRangeSelect: (start: Date, end: Date) => void; @@ -193,6 +213,8 @@ const Tile = forwardRef( onTouchEnd?: (e: React.TouchEvent) => void; children?: React.ReactNode; // Resizer tooltip isHighlighted?: boolean; + isSelected?: boolean; + onSelect?: (tileId: string, shiftKey: boolean) => void; }, ref: ForwardedRef, ) => { @@ -396,40 +418,74 @@ const Tile = forwardRef( > - {onMoveToSection && - availableSections && - availableSections.length > 0 && ( - - + {onMoveToGroup && moveTargets && moveTargets.length > 0 && ( + + + - + - - - Move to Section - {chart.containerId && ( - onMoveToSection(undefined)}> - (Ungrouped) + + + + Move to Group + {chart.containerId && ( + onMoveToGroup(undefined)}> + (Ungrouped) + + )} + {moveTargets + .filter( + t => + !( + t.containerId === chart.containerId && + t.tabId === chart.tabId + ), + ) + .map(t => ( + onMoveToGroup(t.containerId, t.tabId)} + > + {t.allTabs ? ( + + {t.allTabs.map((tab, i) => ( + + {i > 0 && ( + + {' | '} + + )} + + {tab.title} + + + ))} + + ) : ( + t.label + )} - )} - {availableSections - .filter(s => s.id !== chart.containerId) - .map(s => ( - onMoveToSection(s.id)} - > - {s.title} - - ))} - - - )} + ))} + + + )} { + if (e.shiftKey && onSelect) { + e.preventDefault(); + onSelect(chart.id, true); + } }} onMouseDown={onMouseDown} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd} > - - - + {hovered && ( +
+ )}
e.stopPropagation()} @@ -1101,13 +1183,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const [editedTile, setEditedTile] = useState(); - const sections = useMemo( + const containers = useMemo( () => dashboard?.containers ?? [], [dashboard?.containers], ); - const hasSections = sections.length > 0; - - // URL-based collapse state: tracks which sections the current viewer has + // URL-based collapse state: tracks which containers the current viewer has // explicitly collapsed/expanded. Falls back to the DB-stored default. const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState( 'collapsed', @@ -1127,28 +1207,81 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { [urlExpandedIds], ); - const isSectionCollapsed = useCallback( - (section: DashboardContainer): boolean => { + const isContainerCollapsed = useCallback( + (container: DashboardContainer): boolean => { // URL state takes precedence over DB default - if (collapsedIdSet.has(section.id)) return true; - if (expandedIdSet.has(section.id)) return false; - return section.collapsed ?? false; + if (collapsedIdSet.has(container.id)) return true; + if (expandedIdSet.has(container.id)) return false; + return container.collapsed ?? false; }, [collapsedIdSet, expandedIdSet], ); + // Valid move targets: groups and individual tabs within groups + const moveTargetContainers = useMemo(() => { + const targets: MoveTarget[] = []; + for (const c of containers) { + const cTabs = c.tabs ?? []; + if (cTabs.length >= 2) { + for (const tab of cTabs) { + targets.push({ + containerId: c.id, + tabId: tab.id, + label: tab.title, + allTabs: cTabs.map(t => ({ id: t.id, title: t.title })), + }); + } + } else if (cTabs.length === 1) { + // 1-tab group: show just the group name, target the single tab + targets.push({ + containerId: c.id, + tabId: cTabs[0].id, + label: cTabs[0].title, + }); + } else { + targets.push({ containerId: c.id, label: c.title }); + } + } + return targets; + }, [containers]); + + const hasContainers = containers.length > 0; const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); - const handleMoveTileToSection = useCallback( - (tileId: string, containerId: string | undefined) => { + // --- Select-and-group workflow (Shift+click → Cmd+G) --- + const { + selectedTileIds, + setSelectedTileIds, + handleTileSelect, + handleGroupSelected, + } = useTileSelection({ dashboard, setDashboard }); + + const handleMoveTileToGroup = useCallback( + (tileId: string, containerId: string | undefined, tabId?: string) => { if (!dashboard) return; setDashboard( produce(dashboard, draft => { const tile = draft.tiles.find(t => t.id === tileId); - if (tile) { - if (containerId) tile.containerId = containerId; - else delete tile.containerId; - } + if (!tile) return; + + // Update container assignment + if (containerId) tile.containerId = containerId; + else delete tile.containerId; + if (tabId) tile.tabId = tabId; + else delete tile.tabId; + + // Place in next available slot in target grid + const targetTiles = draft.tiles.filter(t => { + if (t.id === tileId) return false; + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition(targetTiles, tile.w, tile.h); + tile.x = pos.x; + tile.y = pos.y; }), ); }, @@ -1239,10 +1372,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }); } }} - containers={sections} - onMoveToSection={containerId => - handleMoveTileToSection(chart.id, containerId) + moveTargets={moveTargetContainers} + onMoveToGroup={(containerId, tabId) => + handleMoveTileToGroup(chart.id, containerId, tabId) } + isSelected={selectedTileIds.has(chart.id)} + onSelect={handleTileSelect} /> ), [ @@ -1258,8 +1393,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, filterQueries, - sections, - handleMoveTileToSection, + moveTargetContainers, + handleMoveTileToGroup, + selectedTileIds, + handleTileSelect, ], ); @@ -1315,10 +1452,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // Toggle collapse in URL state only (per-viewer, shareable via link). // Does NOT persist to DB — the DB `collapsed` field is the default. - const handleToggleSection = useCallback( + const handleToggleCollapse = useCallback( (containerId: string) => { - const section = dashboard?.containers?.find(s => s.id === containerId); - const currentlyCollapsed = section ? isSectionCollapsed(section) : false; + const container = dashboard?.containers?.find(s => s.id === containerId); + const currentlyCollapsed = container + ? isContainerCollapsed(container) + : false; if (currentlyCollapsed) { addToUrlSet(setUrlExpandedIds, containerId); @@ -1330,7 +1469,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }, [ dashboard?.containers, - isSectionCollapsed, + isContainerCollapsed, addToUrlSet, removeFromUrlSet, setUrlCollapsedIds, @@ -1345,116 +1484,123 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { if (!dashboard) return; setDashboard( produce(dashboard, draft => { - const section = draft.containers?.find(s => s.id === containerId); - if (section) section.collapsed = !section.collapsed; + const c = draft.containers?.find(s => s.id === containerId); + if (c) c.collapsed = !c.collapsed; }), ); }, [dashboard, setDashboard], ); - const onAddTile = (containerId?: string) => { - // Auto-expand collapsed section via URL state so the new tile is visible - if (containerId) { - const section = dashboard?.containers?.find(s => s.id === containerId); - if (section && isSectionCollapsed(section)) { - handleToggleSection(containerId); - } - } - setEditedTile({ - id: makeId(), - x: 0, - y: 0, - w: 8, - h: 10, - config: { - ...DEFAULT_CHART_CONFIG, - source: sources?.[0]?.id ?? '', - }, - ...(containerId ? { containerId } : {}), - }); - }; - - const handleAddSection = useCallback(() => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - if (!draft.containers) draft.containers = []; - draft.containers.push({ - id: makeId(), - type: 'section', - title: 'New Section', - collapsed: false, - }); - }), - ); - }, [dashboard, setDashboard]); - - const handleRenameSection = useCallback( - (containerId: string, newTitle: string) => { - if (!dashboard || !newTitle.trim()) return; + const handleToggleCollapsible = useCallback( + (containerId: string) => { + if (!dashboard) return; setDashboard( produce(dashboard, draft => { - const section = draft.containers?.find(s => s.id === containerId); - if (section) section.title = newTitle.trim(); + const c = draft.containers?.find(s => s.id === containerId); + if (c) { + c.collapsible = !(c.collapsible ?? true); + // Ensure container is expanded when collapsing is disabled + if (c.collapsible === false) c.collapsed = false; + } }), ); + // Clear stale URL collapse state so re-enabling doesn't resurrect old state + removeFromUrlSet(setUrlCollapsedIds, containerId); + removeFromUrlSet(setUrlExpandedIds, containerId); }, - [dashboard, setDashboard], + [ + dashboard, + setDashboard, + removeFromUrlSet, + setUrlCollapsedIds, + setUrlExpandedIds, + ], ); - const handleDeleteSection = useCallback( + const handleToggleBordered = useCallback( (containerId: string) => { if (!dashboard) return; setDashboard( produce(dashboard, draft => { - // Find the bottom edge of existing ungrouped tiles so freed - // tiles are placed below them without collision. - const sectionIds = new Set(draft.containers?.map(c => c.id) ?? []); - let maxUngroupedY = 0; - for (const tile of draft.tiles) { - if (!tile.containerId || !sectionIds.has(tile.containerId)) { - maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h); - } - } - - for (const tile of draft.tiles) { - if (tile.containerId === containerId) { - tile.y += maxUngroupedY; - delete tile.containerId; - } - } - - draft.containers = draft.containers?.filter( - s => s.id !== containerId, - ); + const c = draft.containers?.find(s => s.id === containerId); + if (c) c.bordered = !(c.bordered ?? true); }), ); }, [dashboard, setDashboard], ); - // Group tiles by section; orphaned tiles (containerId not matching any - // section) fall back to ungrouped to avoid silently hiding them. + // Use the hook for container/tab CRUD operations, but override + // handleToggleCollapsed with the URL-based version above. + const { + handleAddContainer, + handleToggleCollapsed: _handleToggleCollapsedDB, + handleRenameContainer, + handleDeleteContainer, + handleReorderContainers, + handleAddTab, + handleRenameTab, + handleDeleteTab, + handleTabChange, + } = useDashboardContainers({ dashboard, setDashboard, confirm }); + + const onAddTile = (containerId?: string, tabId?: string) => { + // Auto-expand collapsed container via URL state so the new tile is visible + if (containerId) { + const container = dashboard?.containers?.find(s => s.id === containerId); + if (container && isContainerCollapsed(container)) { + handleToggleCollapse(containerId); + } + } + // Default new tile size: w=8 (1/3 width), h=10 — matches original behavior + const newW = 8; + const newH = 10; + // Place tile in next available slot (fill right, then wrap) + const targetTiles = (dashboard?.tiles ?? []).filter(t => { + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition(targetTiles, newW, newH); + setEditedTile({ + id: makeId(), + x: pos.x, + y: pos.y, + w: newW, + h: newH, + config: { + ...DEFAULT_CHART_CONFIG, + source: sources?.[0]?.id ?? '', + }, + ...(containerId ? { containerId } : {}), + ...(tabId ? { tabId } : {}), + }); + }; + + // Group tiles by container. + // Orphaned tiles (containerId not matching any container) become ungrouped. const tilesByContainerId = useMemo(() => { const map = new Map(); - for (const section of sections) { + for (const c of containers) { map.set( - section.id, - allTiles.filter(t => t.containerId === section.id), + c.id, + allTiles.filter(t => t.containerId === c.id), ); } return map; - }, [sections, allTiles]); + }, [containers, allTiles]); const ungroupedTiles = useMemo( () => - hasSections + hasContainers ? allTiles.filter( t => !t.containerId || !tilesByContainerId.has(t.containerId), ) : allTiles, - [hasSections, allTiles, tilesByContainerId], + [hasContainers, allTiles, tilesByContainerId], ); const onUngroupedLayoutChange = useMemo( @@ -1462,14 +1608,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { [makeOnLayoutChange, ungroupedTiles], ); - const sectionLayoutChangeHandlers = useMemo(() => { + const containerLayoutChangeHandlers = useMemo(() => { const map = new Map void>(); - for (const section of sections) { - const tiles = tilesByContainerId.get(section.id) ?? []; - map.set(section.id, makeOnLayoutChange(tiles)); + for (const c of containers) { + const tiles = tilesByContainerId.get(c.id) ?? []; + map.set(c.id, makeOnLayoutChange(tiles)); } return map; - }, [sections, tilesByContainerId, makeOnLayoutChange]); + }, [containers, tilesByContainerId, makeOnLayoutChange]); const deleteDashboard = useDeleteDashboard(); @@ -1794,6 +1940,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onSetFilterValue={setFilterValue} dateRange={searchedTimeRange} /> + {/* Selection indicator */} + {selectedTileIds.size > 0 && ( + + + + {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} + selected + + + + + + )} {dashboard != null && dashboard.tiles != null ? ( } > - {hasSections ? ( - <> - {ungroupedTiles.length > 0 && ( - - {ungroupedTiles.map(renderTileComponent)} - - )} - {sections.map(section => { - const sectionTiles = tilesByContainerId.get(section.id) ?? []; - return ( -
- handleToggleSection(section.id)} - onToggleDefaultCollapsed={() => - handleToggleDefaultCollapsed(section.id) - } - onRename={newTitle => - handleRenameSection(section.id, newTitle) - } - onDelete={() => handleDeleteSection(section.id)} - onAddTile={() => onAddTile(section.id)} - /> - {!isSectionCollapsed(section) && - sectionTiles.length > 0 && ( - + {hasContainers ? ( + <> + {ungroupedTiles.length > 0 && ( + + {ungroupedTiles.map(renderTileComponent)} + + )} + {containers.map(container => { + const containerTiles = + tilesByContainerId.get(container.id) ?? []; + const groupTabs = container.tabs ?? []; + const groupActiveTabId = + container.activeTabId ?? groupTabs[0]?.id; + const hasTabs = groupTabs.length >= 2; + const containerCollapsed = isContainerCollapsed(container); + + // Compute which tabs have tiles with active alerts. + // Tiles without tabId (single-tab groups) are attributed + // to the first tab so the indicator still shows. + const firstTabId = groupTabs[0]?.id; + const alertingTabIds = new Set(); + for (const tile of containerTiles) { + if ( + isBuilderSavedChartConfig(tile.config) && + tile.config.alert?.state === AlertState.ALERT + ) { + const attributedTabId = tile.tabId ?? firstTabId; + if (attributedTabId) + alertingTabIds.add(attributedTabId); + } + } + + return ( + + {(dragHandleProps: DragHandleProps) => ( + handleToggleCollapse(container.id)} + onToggleDefaultCollapsed={() => + handleToggleDefaultCollapsed(container.id) + } + onToggleCollapsible={() => + handleToggleCollapsible(container.id) + } + onToggleBordered={() => + handleToggleBordered(container.id) + } + onDelete={() => handleDeleteContainer(container.id)} + onAddTile={() => + onAddTile( + container.id, + hasTabs ? groupActiveTabId : undefined, + ) + } + activeTabId={groupActiveTabId} + onTabChange={tabId => + handleTabChange(container.id, tabId) + } + onAddTab={() => handleAddTab(container.id)} + onRenameTab={(tabId, newTitle) => + handleRenameTab(container.id, tabId, newTitle) + } + onDeleteTab={tabId => + handleDeleteTab(container.id, tabId) + } + onRename={newTitle => + handleRenameContainer(container.id, newTitle) + } + dragHandleProps={dragHandleProps} + confirm={confirm} + alertingTabIds={alertingTabIds} > - {sectionTiles.map(renderTileComponent)} - + {(currentTabId: string | undefined) => { + const visibleTiles = currentTabId + ? containerTiles.filter( + t => t.tabId === currentTabId, + ) + : containerTiles; + const visibleIsEmpty = visibleTiles.length === 0; + return ( + + onAddTile(container.id, currentTabId) + } + > + {visibleTiles.length > 0 && ( + + {visibleTiles.map(renderTileComponent)} + + )} + + ); + }} + )} -
- ); - })} - - ) : ( - - {ungroupedTiles.map(renderTileComponent)} - - )} + + ); + })} + + ) : ( + + {ungroupedTiles.map(renderTileComponent)} + + )} +
) : null}
@@ -1889,12 +2139,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { > New Tile + } - onClick={handleAddSection} + data-testid="add-new-group-menu-item" + leftSection={} + onClick={() => handleAddContainer()} > - New Section + New Group diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index bec3e23e74..0e930f9365 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -25,6 +25,7 @@ export type Tile = { h: number; config: SavedChartConfig; containerId?: string; + tabId?: string; }; export type Dashboard = { diff --git a/packages/app/src/utils/tilePositioning.ts b/packages/app/src/utils/tilePositioning.ts index f57d4b66d7..d75be8072c 100644 --- a/packages/app/src/utils/tilePositioning.ts +++ b/packages/app/src/utils/tilePositioning.ts @@ -2,70 +2,86 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Tile } from '@/dashboard'; +const GRID_COLS = 24; + /** * Generate a unique ID for a tile * @returns A random string ID in base 36 */ -export const makeId = () => Math.floor(100000000 * Math.random()).toString(36); +export const makeId = () => + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); /** - * Calculate the next available position for a new tile at the bottom of the dashboard - * @param tiles - Array of existing tiles on the dashboard - * @returns Position object with x and y coordinates + * Calculate the next available position for a new tile, filling right + * then wrapping to the next row (like text in a book). + * + * Scans each row from top to bottom. For each row, checks if there's + * enough horizontal space to fit the new tile. If so, returns that + * position. If no row has space, places at the bottom-left. */ -export function calculateNextTilePosition(tiles: Tile[]): { - x: number; - y: number; -} { +export function calculateNextTilePosition( + tiles: Tile[], + newW: number = 12, + newH: number = 10, +): { x: number; y: number } { if (tiles.length === 0) { return { x: 0, y: 0 }; } - // Find the maximum bottom position (y + height) across all tiles - const maxBottom = Math.max(...tiles.map(tile => tile.y + tile.h)); + // Build a set of occupied rows and find the max bottom + const rows = new Set(); + let maxBottom = 0; + for (const tile of tiles) { + rows.add(tile.y); + maxBottom = Math.max(maxBottom, tile.y + tile.h); + } + + // Check each existing row for horizontal space + const sortedRows = Array.from(rows).sort((a, b) => a - b); + for (const rowY of sortedRows) { + // Find tiles on this row + const rowTiles = tiles.filter(t => t.y <= rowY && t.y + t.h > rowY); + // Calculate rightmost occupied x on this row + let rightEdge = 0; + for (const t of rowTiles) { + rightEdge = Math.max(rightEdge, t.x + t.w); + } + // Check if new tile fits to the right + if (rightEdge + newW <= GRID_COLS) { + return { x: rightEdge, y: rowY }; + } + } - return { - x: 0, // Always start at left edge - y: maxBottom, // Place at bottom of dashboard - }; + // No row has space — place at bottom-left + return { x: 0, y: maxBottom }; } /** * Get default tile dimensions based on chart display type - * @param displayType - The type of chart visualization - * @returns Dimensions object with width (w) and height (h) in grid units */ export function getDefaultTileSize(displayType?: DisplayType): { w: number; h: number; } { - const GRID_COLS = 24; // Full width of dashboard grid - switch (displayType) { case DisplayType.Line: case DisplayType.StackedBar: - // Half-width time series charts return { w: 12, h: 10 }; case DisplayType.Table: case DisplayType.Search: - // Full-width data views return { w: GRID_COLS, h: 12 }; case DisplayType.Number: - // Small metric cards return { w: 6, h: 6 }; case DisplayType.Markdown: - // Medium-sized documentation blocks return { w: 12, h: 8 }; case DisplayType.Heatmap: - // Half-width heatmap return { w: 12, h: 10 }; default: - // Default to half-width time series size return { w: 12, h: 10 }; } } diff --git a/yarn.lock b/yarn.lock index 66180c3557..958f415c38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3408,6 +3408,55 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@dotenvx/dotenvx@npm:^1.51.1": version: 1.51.1 resolution: "@dotenvx/dotenvx@npm:1.51.1" @@ -4371,6 +4420,9 @@ __metadata: "@codemirror/lang-json": "npm:^6.0.1" "@codemirror/lang-sql": "npm:^6.7.0" "@dagrejs/dagre": "npm:^1.1.5" + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@eslint/compat": "npm:^2.0.0" "@hookform/devtools": "npm:^4.3.1" "@hookform/resolvers": "npm:^3.9.0" From d3cf78a15384f3a71d03abf555010e324804a3a4 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:15:57 -0700 Subject: [PATCH 6/6] test: Add GroupContainer and dashboard container tests GroupContainer.test.tsx (329 lines, 18 tests): - Collapsible: chevron show/hide, children toggle, onToggle callback - Bordered: border style present/absent - Collapsed tab summary: pipe-separated names, no summary when expanded or single-tab - Tab bar: renders at 2+ tabs, plain header at 1 tab - Tab delete: confirmation flow, rejected confirmation - Alert indicators: dot on collapsed header, per-tab dot in expanded bar dashboardSections.test.tsx (556 lines, 56 tests): - Schema validation: containers without type field, backward compat with extra fields, tabs, collapsible/bordered options - Tile grouping logic, tab filtering, container authoring operations - Group tab operations: creation, add/delete tabs, rename sync --- .../app/src/__tests__/GroupContainer.test.tsx | 329 +++++++++++ .../src/__tests__/dashboardSections.test.tsx | 556 +++++++++++++++++- 2 files changed, 859 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/__tests__/GroupContainer.test.tsx diff --git a/packages/app/src/__tests__/GroupContainer.test.tsx b/packages/app/src/__tests__/GroupContainer.test.tsx new file mode 100644 index 0000000000..e4b5d1f91f --- /dev/null +++ b/packages/app/src/__tests__/GroupContainer.test.tsx @@ -0,0 +1,329 @@ +import * as React from 'react'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import GroupContainer from '@/components/GroupContainer'; + +function renderGroupContainer( + props: Partial> = {}, +) { + const defaults: React.ComponentProps = { + container: { + id: 'g1', + title: 'Test Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'Tab One' }], + }, + collapsed: false, + defaultCollapsed: false, + onToggle: jest.fn(), + children: () =>
Content
, + ...props, + }; + return render( + + + , + ); +} + +describe('GroupContainer', () => { + describe('collapsible behavior', () => { + it('renders chevron when collapsible (default)', () => { + renderGroupContainer(); + expect(screen.getByTestId('group-chevron-g1')).toBeInTheDocument(); + }); + + it('hides chevron when collapsible is false', () => { + renderGroupContainer({ + container: { + id: 'g1', + title: 'Test', + collapsed: false, + collapsible: false, + tabs: [{ id: 'tab-1', title: 'Tab One' }], + }, + }); + expect(screen.queryByTestId('group-chevron-g1')).not.toBeInTheDocument(); + }); + + it('shows children when expanded', () => { + renderGroupContainer({ collapsed: false }); + expect(screen.getByTestId('group-children')).toBeInTheDocument(); + }); + + it('hides children when collapsed', () => { + renderGroupContainer({ collapsed: true }); + expect(screen.queryByTestId('group-children')).not.toBeInTheDocument(); + }); + + it('calls onToggle when chevron is clicked', () => { + const onToggle = jest.fn(); + renderGroupContainer({ onToggle }); + fireEvent.click(screen.getByTestId('group-chevron-g1')); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('bordered behavior', () => { + it('renders border by default', () => { + renderGroupContainer(); + const container = screen.getByTestId('group-container-g1'); + expect(container.style.border).toContain('1px solid'); + }); + + it('hides border when bordered is false', () => { + renderGroupContainer({ + container: { + id: 'g1', + title: 'Test', + collapsed: false, + bordered: false, + tabs: [{ id: 'tab-1', title: 'Tab One' }], + }, + }); + const container = screen.getByTestId('group-container-g1'); + expect(container.style.border).toBe(''); + }); + }); + + describe('collapsed tab summary', () => { + it('shows all tab names when collapsed with multiple tabs', () => { + renderGroupContainer({ + collapsed: true, + container: { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Overview' }, + { id: 'tab-2', title: 'Details' }, + { id: 'tab-3', title: 'Logs' }, + ], + }, + }); + expect(screen.getByText('Overview | Details | Logs')).toBeInTheDocument(); + }); + + it('does not show tab summary when expanded', () => { + renderGroupContainer({ + collapsed: false, + container: { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Overview' }, + { id: 'tab-2', title: 'Details' }, + ], + }, + }); + expect(screen.queryByText('Overview | Details')).not.toBeInTheDocument(); + }); + + it('shows header title for single-tab collapsed group (no pipe summary)', () => { + renderGroupContainer({ + collapsed: true, + container: { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'Only Tab' }], + }, + }); + // Single tab: shows header title, no pipe-separated summary + expect(screen.getByText('Only Tab')).toBeInTheDocument(); + expect(screen.queryByText(/\|/)).not.toBeInTheDocument(); + }); + }); + + describe('overflow menu conditional rendering', () => { + // Mantine Menu renders dropdown items in a portal only when opened, + // so we test the negative case (items that should NOT be in the DOM). + it('hides default-collapsed toggle when collapsible is false', () => { + renderGroupContainer({ + onToggleDefaultCollapsed: jest.fn(), + container: { + id: 'g1', + title: 'Test', + collapsed: false, + collapsible: false, + tabs: [{ id: 'tab-1', title: 'Tab' }], + }, + }); + expect( + screen.queryByTestId('group-toggle-default-g1'), + ).not.toBeInTheDocument(); + }); + }); + + describe('tab bar', () => { + it('renders tab bar with 2+ tabs when expanded', () => { + renderGroupContainer({ + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'First' }, + { id: 'tab-2', title: 'Second' }, + ], + activeTabId: 'tab-1', + }, + activeTabId: 'tab-1', + onTabChange: jest.fn(), + }); + expect(screen.getByRole('tab', { name: 'First' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Second' })).toBeInTheDocument(); + }); + + it('renders plain header with single tab', () => { + renderGroupContainer({ + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'Only' }], + }, + }); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect(screen.getByText('Only')).toBeInTheDocument(); + }); + }); + + describe('tab delete', () => { + it('calls onDeleteTab with confirmation when confirm is provided', async () => { + const onDeleteTab = jest.fn(); + const confirm = jest.fn().mockResolvedValue(true); + renderGroupContainer({ + onDeleteTab, + confirm, + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'First' }, + { id: 'tab-2', title: 'Second' }, + ], + activeTabId: 'tab-1', + }, + activeTabId: 'tab-1', + onTabChange: jest.fn(), + }); + + // Hover over first tab to reveal delete button + const firstTab = screen.getByRole('tab', { name: 'First' }); + fireEvent.mouseEnter(firstTab); + const deleteBtn = screen.getByTestId('tab-delete-tab-1'); + fireEvent.click(deleteBtn); + + // Wait for async confirm + await screen.findByText('First'); + expect(confirm).toHaveBeenCalledTimes(1); + expect(onDeleteTab).toHaveBeenCalledWith('tab-1'); + }); + + it('does not call onDeleteTab when confirm is rejected', async () => { + const onDeleteTab = jest.fn(); + const confirm = jest.fn().mockResolvedValue(false); + renderGroupContainer({ + onDeleteTab, + confirm, + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'First' }, + { id: 'tab-2', title: 'Second' }, + ], + activeTabId: 'tab-1', + }, + activeTabId: 'tab-1', + onTabChange: jest.fn(), + }); + + const firstTab = screen.getByRole('tab', { name: 'First' }); + fireEvent.mouseEnter(firstTab); + const deleteBtn = screen.getByTestId('tab-delete-tab-1'); + fireEvent.click(deleteBtn); + + // Wait a tick for the async confirm to settle + await new Promise(r => setTimeout(r, 0)); + expect(confirm).toHaveBeenCalledTimes(1); + expect(onDeleteTab).not.toHaveBeenCalled(); + }); + }); + + describe('alert indicators', () => { + it('shows alert dot on collapsed group header when alertingTabIds is non-empty', () => { + const { container: wrapper } = renderGroupContainer({ + collapsed: true, + alertingTabIds: new Set(['tab-1']), + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Overview' }, + { id: 'tab-2', title: 'Logs' }, + ], + }, + }); + // Alert dot is rendered as a small span with red background + const dots = wrapper.querySelectorAll( + 'span[style*="border-radius: 50%"]', + ); + expect(dots.length).toBeGreaterThan(0); + }); + + it('does not show alert dot when alertingTabIds is empty', () => { + const { container: wrapper } = renderGroupContainer({ + collapsed: true, + alertingTabIds: new Set(), + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Overview' }, + { id: 'tab-2', title: 'Logs' }, + ], + }, + }); + const dots = wrapper.querySelectorAll( + 'span[style*="border-radius: 50%"]', + ); + expect(dots.length).toBe(0); + }); + + it('shows alert dot on specific tab in expanded tab bar', () => { + renderGroupContainer({ + collapsed: false, + alertingTabIds: new Set(['tab-2']), + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Overview' }, + { id: 'tab-2', title: 'Alerts' }, + ], + activeTabId: 'tab-1', + }, + activeTabId: 'tab-1', + onTabChange: jest.fn(), + }); + // The "Alerts" tab should have a dot, "Overview" should not + const alertsTab = screen.getByRole('tab', { name: 'Alerts' }); + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + expect( + alertsTab.querySelector('span[style*="border-radius: 50%"]'), + ).toBeTruthy(); + expect( + overviewTab.querySelector('span[style*="border-radius: 50%"]'), + ).toBeNull(); + }); + }); +}); diff --git a/packages/app/src/__tests__/dashboardSections.test.tsx b/packages/app/src/__tests__/dashboardSections.test.tsx index 4f20336565..06413caacf 100644 --- a/packages/app/src/__tests__/dashboardSections.test.tsx +++ b/packages/app/src/__tests__/dashboardSections.test.tsx @@ -5,39 +5,46 @@ import { } from '@hyperdx/common-utils/dist/types'; describe('DashboardContainer schema', () => { - it('validates a valid section', () => { + it('validates a valid group', () => { const result = DashboardContainerSchema.safeParse({ - id: 'section-1', - type: 'section', + id: 'group-1', title: 'Infrastructure', collapsed: false, }); expect(result.success).toBe(true); }); - it('validates a collapsed section', () => { + it('accepts containers with extra fields (backward compat)', () => { const result = DashboardContainerSchema.safeParse({ - id: 'section-2', + id: 'section-1', type: 'section', + title: 'Legacy Section', + collapsed: false, + }); + expect(result.success).toBe(true); + }); + + it('validates a collapsed group', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-2', title: 'Database Metrics', collapsed: true, }); expect(result.success).toBe(true); }); - it('rejects a section missing required fields', () => { + it('rejects a container missing required fields', () => { const result = DashboardContainerSchema.safeParse({ - id: 'section-3', + id: 'group-3', // missing title and collapsed }); expect(result.success).toBe(false); }); - it('rejects a section with empty id or title', () => { + it('rejects a container with empty id or title', () => { expect( DashboardContainerSchema.safeParse({ id: '', - type: 'section', title: 'Valid', collapsed: false, }).success, @@ -45,15 +52,101 @@ describe('DashboardContainer schema', () => { expect( DashboardContainerSchema.safeParse({ id: 'valid', - type: 'section', title: '', collapsed: false, }).success, ).toBe(false); }); + + it('validates a group container without tabs (legacy plain group)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-1', + title: 'Key Metrics', + collapsed: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabs).toBeUndefined(); + } + }); + + it('validates a group with 1 tab (new default for groups)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-new', + title: 'New Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'New Group' }], + activeTabId: 'tab-1', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabs).toHaveLength(1); + expect(result.data.tabs![0].title).toBe('New Group'); + expect(result.data.activeTabId).toBe('tab-1'); + } + }); + + it('validates a group with 2+ tabs (tab bar behavior)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-2', + title: 'Overview Group', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + activeTabId: 'tab-a', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabs).toHaveLength(2); + expect(result.data.activeTabId).toBe('tab-a'); + } + }); + + it('validates a group with 1 tab (plain group, no tab bar)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-3', + title: 'Single Tab Group', + collapsed: false, + tabs: [{ id: 'tab-only', title: 'Only Tab' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabs).toHaveLength(1); + } + }); + + it('validates a group with collapsible and bordered options', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-opts', + title: 'Configurable Group', + collapsed: false, + collapsible: false, + bordered: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.collapsible).toBe(false); + expect(result.data.bordered).toBe(false); + } + }); + + it('defaults collapsible and bordered to undefined (treated as true)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-defaults', + title: 'Default Group', + collapsed: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.collapsible).toBeUndefined(); + expect(result.data.bordered).toBeUndefined(); + } + }); }); -describe('Tile schema with containerId', () => { +describe('Tile schema with containerId and tabId', () => { const baseTile = { id: 'tile-1', x: 0, @@ -79,6 +172,7 @@ describe('Tile schema with containerId', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.containerId).toBeUndefined(); + expect(result.data.tabId).toBeUndefined(); } }); @@ -92,9 +186,33 @@ describe('Tile schema with containerId', () => { expect(result.data.containerId).toBe('section-1'); } }); + + it('validates a tile with containerId and tabId', () => { + const result = TileSchema.safeParse({ + ...baseTile, + containerId: 'group-1', + tabId: 'tab-a', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.containerId).toBe('group-1'); + expect(result.data.tabId).toBe('tab-a'); + } + }); + + it('validates a tile with tabId but no containerId', () => { + const result = TileSchema.safeParse({ + ...baseTile, + tabId: 'orphan-tab', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabId).toBe('orphan-tab'); + } + }); }); -describe('Dashboard schema with sections', () => { +describe('Dashboard schema with containers', () => { const baseDashboard = { id: 'dash-1', name: 'My Dashboard', @@ -121,28 +239,27 @@ describe('Dashboard schema with sections', () => { } }); - it('rejects duplicate section IDs', () => { + it('rejects duplicate container IDs', () => { const result = DashboardSchema.safeParse({ ...baseDashboard, containers: [ - { id: 's1', type: 'section', title: 'Section A', collapsed: false }, - { id: 's1', type: 'section', title: 'Section B', collapsed: true }, + { id: 's1', title: 'Group A', collapsed: false }, + { id: 's1', title: 'Group B', collapsed: true }, ], }); expect(result.success).toBe(false); }); - it('validates a dashboard with sections', () => { + it('validates a dashboard with groups', () => { const result = DashboardSchema.safeParse({ ...baseDashboard, containers: [ { id: 's1', - type: 'section', title: 'Infrastructure', collapsed: false, }, - { id: 's2', type: 'section', title: 'Application', collapsed: true }, + { id: 's2', title: 'Application', collapsed: true }, ], }); expect(result.success).toBe(true); @@ -153,7 +270,22 @@ describe('Dashboard schema with sections', () => { } }); - it('validates a full dashboard with sections and tiles referencing them', () => { + it('old dashboards with type field in containers still parse successfully', () => { + const result = DashboardSchema.safeParse({ + ...baseDashboard, + containers: [ + { + id: 's1', + type: 'section', + title: 'Legacy', + collapsed: false, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('validates a full dashboard with groups and tiles referencing them', () => { const tile = { id: 'tile-1', x: 0, @@ -181,7 +313,6 @@ describe('Dashboard schema with sections', () => { containers: [ { id: 's1', - type: 'section', title: 'Infrastructure', collapsed: false, }, @@ -193,11 +324,57 @@ describe('Dashboard schema with sections', () => { expect(result.data.containers![0].title).toBe('Infrastructure'); } }); + + it('validates a dashboard with group container with tabs and tiles using tabId', () => { + const result = DashboardSchema.safeParse({ + ...baseDashboard, + tiles: [ + { + id: 'tile-1', + x: 0, + y: 0, + w: 8, + h: 10, + containerId: 'g1', + tabId: 'tab-a', + config: { + source: 'source-1', + select: [ + { + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ], + where: '', + from: { databaseName: 'default', tableName: 'logs' }, + }, + }, + ], + containers: [ + { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + activeTabId: 'tab-a', + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tiles[0].tabId).toBe('tab-a'); + expect(result.data.containers![0].tabs).toHaveLength(2); + } + }); }); -describe('section tile grouping logic', () => { +describe('container tile grouping logic', () => { // Test the grouping logic used in DBDashboardPage - type SimpleTile = { id: string; containerId?: string }; + type SimpleTile = { id: string; containerId?: string; tabId?: string }; type SimpleSection = { id: string; title: string; collapsed: boolean }; function groupTilesBySection(tiles: SimpleTile[], sections: SimpleSection[]) { @@ -289,10 +466,78 @@ describe('section tile grouping logic', () => { expect(ungrouped.map(t => t.id)).toEqual(['b', 'c']); expect(bySectionId.get('s1')).toHaveLength(1); }); + + it('filters group tiles by tabId when group has tabs', () => { + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 'g1', tabId: 'tab-1' }, + ]; + const sections: SimpleSection[] = [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + ]; + const { bySectionId } = groupTilesBySection(tiles, sections); + const allGroupTiles = bySectionId.get('g1') ?? []; + expect(allGroupTiles).toHaveLength(3); + // Filter by tabId (as done in DBDashboardPage) + const tab1Tiles = allGroupTiles.filter(t => t.tabId === 'tab-1'); + const tab2Tiles = allGroupTiles.filter(t => t.tabId === 'tab-2'); + expect(tab1Tiles).toHaveLength(2); + expect(tab2Tiles).toHaveLength(1); + }); + + it('group with 0-1 tabs is plain group (no tab filtering)', () => { + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1' }, + { id: 'b', containerId: 'g1' }, + ]; + const sections: SimpleSection[] = [ + { id: 'g1', title: 'Plain Group', collapsed: false }, + ]; + const { bySectionId } = groupTilesBySection(tiles, sections); + const groupTiles = bySectionId.get('g1') ?? []; + // No tab filtering needed for plain groups + expect(groupTiles).toHaveLength(2); + expect(groupTiles.every(t => t.tabId === undefined)).toBe(true); + }); + + it('group with 2+ tabs has tab bar behavior (tiles split by tabId)', () => { + // Simulates the schema: group with tabs array of 2+ entries + type SimpleGroup = SimpleSection & { + tabs?: { id: string; title: string }[]; + activeTabId?: string; + }; + + const group: SimpleGroup = { + id: 'g1', + title: 'Tabbed Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Tab 1' }, + { id: 'tab-2', title: 'Tab 2' }, + ], + activeTabId: 'tab-1', + }; + + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 'g1', tabId: 'tab-1' }, + ]; + + const hasTabs = (group.tabs?.length ?? 0) >= 2; + expect(hasTabs).toBe(true); + + // When tabs exist, render prop receives activeTabId and filters tiles + const activeTabId = group.activeTabId ?? group.tabs![0].id; + const visibleTiles = tiles.filter(t => t.tabId === activeTabId); + expect(visibleTiles).toHaveLength(2); + expect(visibleTiles.map(t => t.id)).toEqual(['a', 'c']); + }); }); -describe('section authoring operations', () => { - type SimpleTile = { id: string; containerId?: string }; +describe('container authoring operations', () => { + type SimpleTile = { id: string; containerId?: string; tabId?: string }; type SimpleSection = { id: string; title: string; collapsed: boolean }; type SimpleDashboard = { tiles: SimpleTile[]; @@ -324,7 +569,9 @@ describe('section authoring operations', () => { ...dashboard, containers: dashboard.containers?.filter(s => s.id !== containerId), tiles: dashboard.tiles.map(t => - t.containerId === containerId ? { ...t, containerId: undefined } : t, + t.containerId === containerId + ? { ...t, containerId: undefined, tabId: undefined } + : t, ), }; } @@ -345,11 +592,12 @@ describe('section authoring operations', () => { dashboard: SimpleDashboard, tileId: string, containerId: string | undefined, + tabId?: string, ) { return { ...dashboard, tiles: dashboard.tiles.map(t => - t.id === tileId ? { ...t, containerId } : t, + t.id === tileId ? { ...t, containerId, tabId } : t, ), }; } @@ -462,6 +710,25 @@ describe('section authoring operations', () => { expect(result.tiles.find(t => t.id === 'd')?.containerId).toBeUndefined(); }); + it('clears tabId when deleting a group with tabs', () => { + const dashboard: SimpleDashboard = { + tiles: [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 's1' }, + ], + containers: [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + { id: 's1', title: 'Section', collapsed: false }, + ], + }; + const result = deleteSection(dashboard, 'g1'); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'a')?.tabId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'b')?.tabId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('s1'); + }); + it('handles deleting the last section', () => { const dashboard: SimpleDashboard = { tiles: [{ id: 'a', containerId: 's1' }], @@ -524,5 +791,242 @@ describe('section authoring operations', () => { const result = moveTileToSection(dashboard, 'a', undefined); expect(result.tiles[0].containerId).toBeUndefined(); }); + + it('moves a tile to a specific tab in a group', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a' }], + containers: [{ id: 'g1', title: 'Group with Tabs', collapsed: false }], + }; + const result = moveTileToSection(dashboard, 'a', 'g1', 'tab-1'); + expect(result.tiles[0].containerId).toBe('g1'); + expect(result.tiles[0].tabId).toBe('tab-1'); + }); + + it('clears tabId when moving from group tab to regular section', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a', containerId: 'g1', tabId: 'tab-1' }], + containers: [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + { id: 's1', title: 'Section', collapsed: false }, + ], + }; + const result = moveTileToSection(dashboard, 'a', 's1'); + expect(result.tiles[0].containerId).toBe('s1'); + expect(result.tiles[0].tabId).toBeUndefined(); + }); + }); + + describe('reorder sections', () => { + function reorderSections( + dashboard: SimpleDashboard, + fromIndex: number, + toIndex: number, + ) { + if (!dashboard.containers) return dashboard; + const containers = [...dashboard.containers]; + const [removed] = containers.splice(fromIndex, 1); + containers.splice(toIndex, 0, removed); + return { ...dashboard, containers }; + } + + it('moves a section from first to last', () => { + const dashboard: SimpleDashboard = { + tiles: [], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + { id: 's3', title: 'Third', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 0, 2); + expect(result.containers!.map(c => c.id)).toEqual(['s2', 's3', 's1']); + }); + + it('moves a section from last to first', () => { + const dashboard: SimpleDashboard = { + tiles: [], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + { id: 's3', title: 'Third', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 2, 0); + expect(result.containers!.map(c => c.id)).toEqual(['s3', 's1', 's2']); + }); + + it('does not affect tiles when sections are reordered', () => { + const dashboard: SimpleDashboard = { + tiles: [ + { id: 'a', containerId: 's1' }, + { id: 'b', containerId: 's2' }, + ], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 0, 1); + expect(result.tiles).toEqual(dashboard.tiles); + expect(result.containers!.map(c => c.id)).toEqual(['s2', 's1']); + }); + }); + + describe('group selected tiles', () => { + function groupTilesIntoSection( + dashboard: SimpleDashboard, + tileIds: string[], + newSection: SimpleSection, + ) { + const containers = [...(dashboard.containers ?? []), newSection]; + const tiles = dashboard.tiles.map(t => + tileIds.includes(t.id) ? { ...t, containerId: newSection.id } : t, + ); + return { ...dashboard, containers, tiles }; + } + + it('groups selected tiles into a new section', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }; + const result = groupTilesIntoSection(dashboard, ['a', 'c'], { + id: 'new-s', + title: 'New Section', + collapsed: false, + }); + expect(result.containers).toHaveLength(1); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBe('new-s'); + expect(result.tiles.find(t => t.id === 'b')?.containerId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('new-s'); + }); + + it('preserves existing sections when grouping', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a', containerId: 's1' }, { id: 'b' }, { id: 'c' }], + containers: [{ id: 's1', title: 'Existing', collapsed: false }], + }; + const result = groupTilesIntoSection(dashboard, ['b', 'c'], { + id: 'new-s', + title: 'Grouped', + collapsed: false, + }); + expect(result.containers).toHaveLength(2); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBe('s1'); + expect(result.tiles.find(t => t.id === 'b')?.containerId).toBe('new-s'); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('new-s'); + }); + }); +}); + +describe('group tab operations', () => { + type SimpleTab = { id: string; title: string }; + type SimpleGroup = { + id: string; + title: string; + collapsed: boolean; + tabs?: SimpleTab[]; + activeTabId?: string; + }; + type SimpleTile = { id: string; containerId?: string; tabId?: string }; + + it('group creation always has 1 tab', () => { + // Simulates handleAddContainer('group') + const tabId = 'tab-new'; + const group: SimpleGroup = { + id: 'g1', + title: 'New Group', + collapsed: false, + tabs: [{ id: tabId, title: 'New Group' }], + activeTabId: tabId, + }; + + expect(group.tabs).toHaveLength(1); + expect(group.tabs![0].title).toBe('New Group'); + expect(group.activeTabId).toBe(tabId); + }); + + it('adding tab to 1-tab group creates second tab without renaming first', () => { + // Simulates handleAddTab for a group with 1 tab + const group: SimpleGroup = { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'My Group' }], + activeTabId: 'tab-1', + }; + const tiles: SimpleTile[] = [{ id: 'a', containerId: 'g1' }]; + + // Add second tab (simulates the hook logic) + const newTabId = 'tab-2'; + const updatedTabs = [...group.tabs!, { id: newTabId, title: 'New Tab' }]; + const updatedTiles = tiles.map(t => + t.containerId === 'g1' && !t.tabId ? { ...t, tabId: 'tab-1' } : t, + ); + + expect(updatedTabs).toHaveLength(2); + expect(updatedTabs[0].title).toBe('My Group'); // First tab NOT renamed + expect(updatedTabs[1].title).toBe('New Tab'); + expect(updatedTiles[0].tabId).toBe('tab-1'); + }); + + it('group title syncs from tabs[0].title for 1-tab groups', () => { + // Simulates handleRenameSection for a group with 1 tab + const group: SimpleGroup = { + id: 'g1', + title: 'Old Name', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'Old Name' }], + activeTabId: 'tab-1', + }; + + // Rename via header (which syncs to tabs[0]) + const newTitle = 'New Name'; + const updatedGroup = { + ...group, + title: newTitle, + tabs: group.tabs!.map((t, i) => + i === 0 ? { ...t, title: newTitle } : t, + ), + }; + + expect(updatedGroup.title).toBe('New Name'); + expect(updatedGroup.tabs![0].title).toBe('New Name'); + }); + + it('removing to 1 tab keeps the tab in the array', () => { + // Simulates handleDeleteTab leaving 1 tab + const group: SimpleGroup = { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Tab A' }, + { id: 'tab-2', title: 'Tab B' }, + ], + activeTabId: 'tab-1', + }; + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + ]; + + // Delete tab-2, keep tab-1 + const deletedTabId = 'tab-2'; + const remaining = group.tabs!.filter(t => t.id !== deletedTabId); + const keepTab = remaining[0]; + + // Move tiles from deleted tab to remaining tab + const updatedTiles = tiles.map(t => + t.containerId === 'g1' && t.tabId === deletedTabId + ? { ...t, tabId: keepTab.id } + : t, + ); + + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe('tab-1'); + // All tiles should now reference the remaining tab + expect(updatedTiles.every(t => t.tabId === 'tab-1')).toBe(true); + // Tab bar hidden because only 1 tab remains (rendering handles this) + expect(remaining.length >= 2).toBe(false); }); });