From 69a337acb77256aabeed47b2c4686ab870b281a9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:39 -0700 Subject: [PATCH 01/10] 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 639d885820..65dba61982 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -756,6 +756,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({ @@ -767,11 +769,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 33ea2bb3adf15ff22e8765d65ba2bafeff928563 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:46 -0700 Subject: [PATCH 02/10] 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 cc4d62f4d14588b528939a062de72209065b16ef Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:14:57 -0700 Subject: [PATCH 03/10] 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 7742b48c4032971f6ca05b1bfc4f689f6c2ad565 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 3 Apr 2026 15:15:04 -0700 Subject: [PATCH 04/10] 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 bcce77111fcff8297e0fc01f6df9985469191ed7 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Mon, 6 Apr 2026 08:40:44 -0700 Subject: [PATCH 05/10] 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 1710c36a4e..c02ab04147 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,6 +32,9 @@ "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.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.17.0", diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a28f8a276b..36c6d47441 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -65,15 +65,16 @@ import { IconBell, IconChartBar, IconCopy, + IconCornerDownRight, IconDeviceFloppy, IconDotsVertical, IconDownload, IconFilterEdit, - IconLayoutList, IconPencil, IconPlayerPlay, IconPlus, IconRefresh, + IconSquaresDiagonal, IconTags, IconTrash, IconUpload, @@ -82,13 +83,21 @@ 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 { FavoriteButton } from '@/components/FavoriteButton'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; -import SectionHeader from '@/components/SectionHeader'; +import GroupContainer from '@/components/GroupContainer'; import { TimePicker } from '@/components/TimePicker'; import { Dashboard, @@ -96,6 +105,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'; @@ -107,6 +118,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'; @@ -131,10 +143,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, @@ -161,8 +179,8 @@ const Tile = forwardRef( onEditClick, onDeleteClick, onUpdateChart, - onMoveToSection, - containers: availableSections, + onMoveToGroup, + moveTargets, granularity, onTimeRangeSelect, filters, @@ -175,6 +193,8 @@ const Tile = forwardRef( onTouchEnd, children, isHighlighted, + isSelected, + onSelect, }: { chart: Tile; dateRange: [Date, Date]; @@ -183,8 +203,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; @@ -198,6 +218,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, ) => { @@ -420,40 +442,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()} @@ -1063,13 +1145,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', @@ -1089,28 +1169,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; }), ); }, @@ -1201,10 +1334,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} /> ), [ @@ -1220,8 +1355,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, filterQueries, - sections, - handleMoveTileToSection, + moveTargetContainers, + handleMoveTileToGroup, + selectedTileIds, + handleTileSelect, ], ); @@ -1277,10 +1414,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); @@ -1292,7 +1431,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }, [ dashboard?.containers, - isSectionCollapsed, + isContainerCollapsed, addToUrlSet, removeFromUrlSet, setUrlCollapsedIds, @@ -1307,116 +1446,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( @@ -1424,14 +1570,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(); @@ -1787,6 +1933,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}
@@ -1882,12 +2132,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 5c1050e8d2..3397121e1f 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" @@ -4310,6 +4359,9 @@ __metadata: "@codemirror/lint": "npm:^6.0.0" "@codemirror/state": "npm:^6.0.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-react/eslint-plugin": "npm:^3.0.0" "@eslint/compat": "npm:^2.0.0" "@hookform/resolvers": "npm:^3.9.0" From c244076fcc6aee6665746b560b14e50907c03534 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Mon, 6 Apr 2026 08:40:53 -0700 Subject: [PATCH 06/10] 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); }); }); From e501956e10d11df08d550d44a75ad45a37753b4f Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:42:41 -0700 Subject: [PATCH 07/10] fix: use semantic CSS tokens per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace mantine-specific CSS variables with semantic design tokens: - var(--mantine-color-body) → var(--color-bg-body) - var(--mantine-color-default-border) → var(--color-border) - var(--mantine-color-red-filled) → var(--color-bg-danger) Addresses review comments from elizabetdev on PR #2015. --- .../app/src/components/DashboardDndContext.tsx | 4 ++-- packages/app/src/components/GroupContainer.tsx | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/DashboardDndContext.tsx b/packages/app/src/components/DashboardDndContext.tsx index 26145d0168..45389199ae 100644 --- a/packages/app/src/components/DashboardDndContext.tsx +++ b/packages/app/src/components/DashboardDndContext.tsx @@ -99,8 +99,8 @@ export function DashboardDndProvider({ px="sm" py={4} style={{ - background: 'var(--mantine-color-body)', - border: '1px solid var(--mantine-color-default-border)', + background: 'var(--color-bg-body)', + border: '1px solid var(--color-border)', borderRadius: 4, opacity: 0.85, }} diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx index be62884acd..b8897f5546 100644 --- a/packages/app/src/components/GroupContainer.tsx +++ b/packages/app/src/components/GroupContainer.tsx @@ -20,7 +20,7 @@ function AlertDot({ size = 6 }: { size?: number }) { width: size, height: size, borderRadius: '50%', - backgroundColor: 'var(--mantine-color-red-filled)', + backgroundColor: 'var(--color-bg-danger)', flexShrink: 0, }} /> @@ -269,14 +269,14 @@ export default function GroupContainer({
); - // Collapsed header: pipe-separated tab names (max 4, then "…") + // Collapsed header: pipe-separated tab names (max 4, then "\u2026") 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 ? ' | …' : '') + .join(' | ') + (tabs.length > MAX_COLLAPSED_TABS ? ' | \u2026' : '') : null; // Tab IDs with active alerts (for indicators) @@ -291,9 +291,7 @@ export default function GroupContainer({ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ - border: bordered - ? '1px solid var(--mantine-color-default-border)' - : undefined, + border: bordered ? '1px solid var(--color-border)' : undefined, borderRadius: bordered ? 4 : undefined, marginTop: 8, }} @@ -309,7 +307,7 @@ export default function GroupContainer({ px="sm" gap={6} style={{ - borderBottom: '1px solid var(--mantine-color-default-border)', + borderBottom: '1px solid var(--color-border)', minHeight: headerHeight, }} > @@ -427,7 +425,7 @@ export default function GroupContainer({ ) : ( - /* Plain header (1 tab or collapsed) — shows title + chevron */ + /* Plain header (1 tab or collapsed) \u2014 shows title + chevron */ Date: Tue, 7 Apr 2026 16:21:47 -0700 Subject: [PATCH 08/10] fix: Replace remaining Mantine tokens with semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --mantine-color-dimmed → --color-text-muted (chevron, drag handle) - --mantine-color-default-border → --color-border (plain header) - Remove dashed border from empty container placeholder (avoids border-inside-border per review feedback) --- packages/app/src/components/DashboardDndComponents.tsx | 4 ---- packages/app/src/components/GroupContainer.tsx | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/DashboardDndComponents.tsx b/packages/app/src/components/DashboardDndComponents.tsx index f890163b31..b80b2ecdd5 100644 --- a/packages/app/src/components/DashboardDndComponents.tsx +++ b/packages/app/src/components/DashboardDndComponents.tsx @@ -25,10 +25,6 @@ export function EmptyContainerPlaceholder({ data-testid={`container-placeholder-${containerId}`} style={{ minHeight: isEmpty ? 80 : undefined, - borderRadius: 4, - border: isEmpty - ? '2px dashed var(--mantine-color-default-border)' - : undefined, position: 'relative', }} > diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx index b8897f5546..99dd0f252c 100644 --- a/packages/app/src/components/GroupContainer.tsx +++ b/packages/app/src/components/GroupContainer.tsx @@ -147,7 +147,7 @@ export default function GroupContainer({ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)', transition: 'transform 150ms ease', flexShrink: 0, - color: 'var(--mantine-color-dimmed)', + color: 'var(--color-text-muted)', cursor: 'pointer', }} onClick={onToggle} @@ -264,7 +264,7 @@ export default function GroupContainer({ >
); @@ -433,7 +433,7 @@ export default function GroupContainer({ style={{ borderBottom: isCollapsed ? undefined - : '1px solid var(--mantine-color-default-border)', + : '1px solid var(--color-border)', minHeight: headerHeight, }} > From dbec2c144ec8929e1adeff44438c33b654efecf6 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Thu, 9 Apr 2026 17:01:19 -0700 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20Address=20PR=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20Mantine=20components,=20decomposition,=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace raw divs/spans with Mantine Box, Flex, Center, TextInput components - Extract GroupTabBar sub-component from GroupContainer (~180 lines) - Extract DashboardGroupItem to eliminate double-nested inline rendering - Pre-compute alertingTabIdsByContainer as useMemo outside render loop - Rename state: editing→isRenamingGroup, editedTitle→groupRenameValue, etc. - Merge 1-tab and 2+-tab cases in handleAddTab - Remove default params from calculateNextTilePosition (callers pass explicit) - Use semantic token --color-outline-focus for tile selection outline - Remove unnecessary hasContainers ternary in render - Add comment explaining tab/title sync semantics --- packages/app/src/DBDashboardPage.tsx | 309 +++++++++++------- .../src/components/DashboardDndComponents.tsx | 29 +- .../app/src/components/GroupContainer.tsx | 285 +++++----------- packages/app/src/components/GroupTabBar.tsx | 202 ++++++++++++ .../src/components/SaveToDashboardModal.tsx | 2 +- .../app/src/hooks/useDashboardContainers.tsx | 34 +- packages/app/src/utils/tilePositioning.ts | 8 +- 7 files changed, 491 insertions(+), 378 deletions(-) create mode 100644 packages/app/src/components/GroupTabBar.tsx diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 36c6d47441..a507290e84 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -711,7 +711,7 @@ const Tile = forwardRef( ...style, ...(isSelected ? { - outline: '2px solid var(--mantine-color-blue-5)', + outline: '2px solid var(--color-outline-focus)', outlineOffset: -2, } : {}), @@ -869,6 +869,111 @@ function downloadObjectAsJson(object: object, fileName = 'output') { downloadAnchorNode.remove(); } +// Extracted component for rendering a single dashboard group/container. +// Eliminates the double-nested inline function rendering pattern. +function DashboardGroupItem({ + container, + containerTiles, + isCollapsed, + alertingTabIds, + onToggleCollapse, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDeleteContainer, + onAddTile, + onAddTab, + onRenameTab, + onDeleteTab, + onRenameContainer, + onTabChange, + dragHandleProps, + confirm, + layoutChangeHandler, + tileToLayoutItem, + renderTileComponent, +}: { + container: DashboardContainer; + containerTiles: Tile[]; + isCollapsed: boolean; + alertingTabIds?: Set; + onToggleCollapse: () => void; + onToggleDefaultCollapsed: () => void; + onToggleCollapsible: () => void; + onToggleBordered: () => void; + onDeleteContainer: () => void; + onAddTile: (containerId: string, tabId?: string) => void; + onAddTab: () => void; + onRenameTab: (tabId: string, newTitle: string) => void; + onDeleteTab: (tabId: string) => void; + onRenameContainer: (newTitle: string) => void; + onTabChange: (tabId: string) => void; + dragHandleProps: DragHandleProps; + confirm: ( + message: React.ReactNode, + confirmLabel?: string, + options?: { variant?: 'primary' | 'danger' }, + ) => Promise; + layoutChangeHandler?: (newLayout: RGL.Layout[]) => void; + tileToLayoutItem: (tile: Tile) => RGL.Layout; + renderTileComponent: (tile: Tile) => React.ReactNode; +}) { + const groupTabs = container.tabs ?? []; + const groupActiveTabId = container.activeTabId ?? groupTabs[0]?.id; + const hasTabs = groupTabs.length >= 2; + + return ( + + onAddTile(container.id, hasTabs ? groupActiveTabId : undefined) + } + activeTabId={groupActiveTabId} + onTabChange={onTabChange} + onAddTab={onAddTab} + onRenameTab={onRenameTab} + onDeleteTab={onDeleteTab} + onRename={onRenameContainer} + dragHandleProps={dragHandleProps} + confirm={confirm} + alertingTabIds={alertingTabIds} + > + {(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)} + + )} + + ); + }} + + ); +} + function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const brandName = useBrandDisplayName(); const confirm = useConfirm(); @@ -1555,6 +1660,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { return map; }, [containers, allTiles]); + // Pre-compute which tabs have alerting tiles per container + const alertingTabIdsByContainer = useMemo(() => { + const map = new Map>(); + for (const container of containers) { + const tiles = tilesByContainerId.get(container.id) ?? []; + const firstTabId = container.tabs?.[0]?.id; + const alerting = new Set(); + for (const tile of tiles) { + if ( + isBuilderSavedChartConfig(tile.config) && + tile.config.alert?.state === AlertState.ALERT + ) { + const attributedTabId = tile.tabId ?? firstTabId; + if (attributedTabId) alerting.add(attributedTabId); + } + } + if (alerting.size > 0) map.set(container.id, alerting); + } + return map; + }, [containers, tilesByContainerId]); + const ungroupedTiles = useMemo( () => hasContainers @@ -1973,130 +2099,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { containers={containers} onReorderContainers={handleReorderContainers} > - {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} - > - {(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.length > 0 && ( )} + {containers.map(container => ( + + {(dragHandleProps: DragHandleProps) => ( + + handleToggleCollapse(container.id) + } + onToggleDefaultCollapsed={() => + handleToggleDefaultCollapsed(container.id) + } + onToggleCollapsible={() => + handleToggleCollapsible(container.id) + } + onToggleBordered={() => + handleToggleBordered(container.id) + } + onDeleteContainer={() => + handleDeleteContainer(container.id) + } + onAddTile={onAddTile} + onAddTab={() => handleAddTab(container.id)} + onRenameTab={(tabId, title) => + handleRenameTab(container.id, tabId, title) + } + onDeleteTab={tabId => + handleDeleteTab(container.id, tabId) + } + onRenameContainer={title => + handleRenameContainer(container.id, title) + } + onTabChange={tabId => + handleTabChange(container.id, tabId) + } + dragHandleProps={dragHandleProps} + confirm={confirm} + layoutChangeHandler={containerLayoutChangeHandlers.get( + container.id, + )} + tileToLayoutItem={tileToLayoutItem} + renderTileComponent={renderTileComponent} + /> + )} + + ))} ) : null} diff --git a/packages/app/src/components/DashboardDndComponents.tsx b/packages/app/src/components/DashboardDndComponents.tsx index b80b2ecdd5..254c586cfb 100644 --- a/packages/app/src/components/DashboardDndComponents.tsx +++ b/packages/app/src/components/DashboardDndComponents.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Box, Button } from '@mantine/core'; +import { Box, Button, Center } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { type DragData, type DragHandleProps } from './DashboardDndContext'; @@ -21,24 +21,13 @@ export function EmptyContainerPlaceholder({ onAddTile?: () => void; }) { return ( -
{isEmpty && ( - +
- +
)} {children} -
+ ); } @@ -89,8 +78,8 @@ export function SortableContainerWrapper({ }; return ( -
+ {children({ ...attributes, ...listeners })} -
+ ); } diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx index 99dd0f252c..d08e17b988 100644 --- a/packages/app/src/components/GroupContainer.tsx +++ b/packages/app/src/components/GroupContainer.tsx @@ -1,31 +1,25 @@ import { useState } from 'react'; import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; -import { ActionIcon, Flex, Menu, Tabs, Text, Tooltip } from '@mantine/core'; +import { + ActionIcon, + Box, + Flex, + Menu, + Tabs, + Text, + TextInput, + 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 ( - - ); -} +import GroupTabBar, { AlertDot } from '@/components/GroupTabBar'; type GroupContainerProps = { container: DashboardContainer; @@ -75,12 +69,9 @@ export default function GroupContainer({ confirm, alertingTabIds, }: GroupContainerProps) { - const [editing, setEditing] = useState(false); - const [editedTitle, setEditedTitle] = useState(container.title); + const [isRenamingGroup, setIsRenamingGroup] = useState(false); + const [groupRenameValue, setGroupRenameValue] = 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 ?? []; @@ -94,8 +85,8 @@ export default function GroupContainer({ const firstTab = tabs[0]; const headerTitle = firstTab?.title ?? container.title; - const handleSaveRename = () => { - const trimmed = editedTitle.trim(); + const handleSaveGroupRename = () => { + const trimmed = groupRenameValue.trim(); if (trimmed && trimmed !== headerTitle) { if (firstTab) { onRenameTab?.(firstTab.id, trimmed); @@ -103,37 +94,17 @@ export default function GroupContainer({ onRename?.(trimmed); } } else { - setEditedTitle(headerTitle); + setGroupRenameValue(headerTitle); } - setEditing(false); + setIsRenamingGroup(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); + // Visibility style for controls that appear on hover + const hoverControlStyle = { + opacity: showControls ? 1 : 0, + pointerEvents: (showControls + ? 'auto' + : 'none') as React.CSSProperties['pointerEvents'], }; const chevron = collapsible ? ( @@ -161,17 +132,13 @@ export default function GroupContainer({ /> ) : null; - // Single "Add Tile" button (1 click) shown on hover, plus "Add Tab" in overflow const addTileButton = !isCollapsed && onAddTile && ( @@ -187,10 +154,7 @@ export default function GroupContainer({ variant="subtle" size="sm" tabIndex={showControls ? 0 : -1} - style={{ - opacity: showControls ? 1 : 0, - pointerEvents: showControls ? 'auto' : 'none', - }} + style={hoverControlStyle} > @@ -249,13 +213,12 @@ export default function GroupContainer({ ); const dragHandle = dragHandleProps && ( -
-
+ ); // Collapsed header: pipe-separated tab names (max 4, then "\u2026") @@ -279,21 +242,20 @@ export default function GroupContainer({ .join(' | ') + (tabs.length > MAX_COLLAPSED_TABS ? ' | \u2026' : '') : 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)} + mt={8} style={{ border: bordered ? '1px solid var(--color-border)' : undefined, borderRadius: bordered ? 4 : undefined, - marginTop: 8, }} > {hasTabs && !isCollapsed ? ( @@ -306,170 +268,73 @@ export default function GroupContainer({ align="center" px="sm" gap={6} - style={{ - borderBottom: '1px solid var(--color-border)', - minHeight: headerHeight, - }} + mih={headerHeight} + style={{ borderBottom: '1px solid var(--color-border)' }} > {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) \u2014 shows title + chevron */ + /* Plain header (1 tab or collapsed) — shows title + chevron */ {dragHandle} {chevron} - {editing ? ( + {isRenamingGroup ? (
{ e.preventDefault(); - handleSaveRename(); + handleSaveGroupRename(); }} style={{ flex: 1 }} > - setEditedTitle(e.target.value)} - onBlur={handleSaveRename} + setGroupRenameValue(e.target.value)} + onBlur={handleSaveGroupRename} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Escape') { - setEditedTitle(headerTitle); - setEditing(false); + setGroupRenameValue(headerTitle); + setIsRenamingGroup(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%', + size="sm" + fw={500} + w="100%" + styles={{ + input: { + padding: 0, + margin: 0, + minHeight: 'auto', + height: 'auto', + }, }} data-testid={`group-rename-input-${container.id}`} /> @@ -478,11 +343,9 @@ export default function GroupContainer({ { e.stopPropagation(); - setEditedTitle(headerTitle); - setEditing(true); + setGroupRenameValue(headerTitle); + setIsRenamingGroup(true); } : undefined } @@ -515,10 +378,8 @@ export default function GroupContainer({ )} {!isCollapsed && ( -
- {children(hasTabs ? resolvedActiveTabId : undefined)} -
+ {children(hasTabs ? resolvedActiveTabId : undefined)} )} -
+ ); } diff --git a/packages/app/src/components/GroupTabBar.tsx b/packages/app/src/components/GroupTabBar.tsx new file mode 100644 index 0000000000..401a4e72dd --- /dev/null +++ b/packages/app/src/components/GroupTabBar.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Flex, + Tabs, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconPencil, IconTrash } from '@tabler/icons-react'; + +function AlertDot({ size = 6 }: { size?: number }) { + return ( + + ); +} + +export { AlertDot }; + +type GroupTabBarProps = { + tabs: NonNullable; + activeTabId: string | undefined; + showControls: boolean; + onTabChange?: (tabId: string) => void; + onRenameTab?: (tabId: string, newTitle: string) => void; + onDeleteTab?: (tabId: string) => void; + containerId: string; + alertingTabIds?: Set; + confirm?: ( + message: React.ReactNode, + confirmLabel?: string, + options?: { variant?: 'primary' | 'danger' }, + ) => Promise; + hoverControlStyle: React.CSSProperties; +}; + +export default function GroupTabBar({ + tabs, + activeTabId, + showControls, + onTabChange, + onRenameTab, + onDeleteTab, + containerId, + alertingTabIds, + confirm, + hoverControlStyle, +}: GroupTabBarProps) { + const [renamingTabId, setRenamingTabId] = useState(null); + const [tabRenameValue, setTabRenameValue] = useState(''); + const [hoveredTabId, setHoveredTabId] = useState(null); + + const handleCommitTabRename = (tabId: string) => { + const trimmed = tabRenameValue.trim(); + const tab = tabs.find(t => t.id === tabId); + if (trimmed && tab && trimmed !== tab.title) { + onRenameTab?.(tabId, trimmed); + } + setRenamingTabId(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); + }; + + return ( + <> + + {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 + ? () => { + setRenamingTabId(tab.id); + setTabRenameValue(tab.title); + } + : undefined + } + > + {renamingTabId === tab.id ? ( + { + e.preventDefault(); + handleCommitTabRename(tab.id); + }} + onClick={e => e.stopPropagation()} + style={{ display: 'inline' }} + > + setTabRenameValue(e.target.value)} + onBlur={() => handleCommitTabRename(tab.id)} + onKeyDown={e => { + e.stopPropagation(); + if (e.key === 'Escape') setRenamingTabId(null); + }} + autoFocus + size="xs" + w={`${Math.max(tabRenameValue.length, 3)}ch`} + styles={{ + input: { + padding: 0, + margin: 0, + minHeight: 'auto', + height: 'auto', + font: 'inherit', + color: 'inherit', + }, + }} + data-testid={`tab-rename-input-${tab.id}`} + /> + + ) : ( + + {tab.title} + {alertingTabIds?.has(tab.id) && } + + )} + + ))} + + {/* Rename active tab button */} + {onRenameTab && activeTabId && ( + + { + const tab = tabs.find(t => t.id === activeTabId); + if (tab) { + setRenamingTabId(tab.id); + setTabRenameValue(tab.title); + } + }} + data-testid={`tab-rename-btn-${containerId}`} + > + + + + )} + + ); +} diff --git a/packages/app/src/components/SaveToDashboardModal.tsx b/packages/app/src/components/SaveToDashboardModal.tsx index ef7eb38797..9f23376353 100644 --- a/packages/app/src/components/SaveToDashboardModal.tsx +++ b/packages/app/src/components/SaveToDashboardModal.tsx @@ -75,8 +75,8 @@ export default function SaveToDashboardModal({ ]; const createNewTile = (dashboard: Dashboard): Tile => { - const position = calculateNextTilePosition(dashboard.tiles); const size = getDefaultTileSize(chartConfig.displayType); + const position = calculateNextTilePosition(dashboard.tiles, size.w, size.h); return { id: makeId(), diff --git a/packages/app/src/hooks/useDashboardContainers.tsx b/packages/app/src/hooks/useDashboardContainers.tsx index edbc0ecab8..f25811ffff 100644 --- a/packages/app/src/hooks/useDashboardContainers.tsx +++ b/packages/app/src/hooks/useDashboardContainers.tsx @@ -12,6 +12,13 @@ type ConfirmFn = ( options?: { variant?: 'primary' | 'danger' }, ) => Promise; +// Tab/title semantics: +// Every container has a `title` field used as the display name. +// When a container has a single tab, `container.title` and `tabs[0].title` +// are kept in sync — renaming either updates both. This means the header +// always shows the tab's title. When there are 2+ tabs, `container.title` +// tracks the first tab's title (for collapsed/serialized views) while each +// tab has its own independent title shown in the tab bar. export default function useDashboardContainers({ dashboard, setDashboard, @@ -162,21 +169,9 @@ export default function useDashboardContainers({ 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 + if (existingTabs.length === 0) { + // Legacy container with no tabs: create 2 tabs and assign + // all existing tiles to the first tab const tab1Id = makeId(); const tab2Id = makeId(); c.tabs = [ @@ -190,7 +185,7 @@ export default function useDashboardContainers({ } } } else { - // Already has 2+ tabs, add one more + // 1+ tabs: add a new tab and ensure tiles have a tabId if (!c.tabs) c.tabs = []; const newTabId = makeId(); c.tabs.push({ @@ -198,6 +193,13 @@ export default function useDashboardContainers({ title: `Tab ${existingTabs.length + 1}`, }); c.activeTabId = newTabId; + // Assign any orphaned tiles (no tabId) to the first tab + const firstTabId = existingTabs[0].id; + for (const tile of draft.tiles) { + if (tile.containerId === containerId && !tile.tabId) { + tile.tabId = firstTabId; + } + } } }), ); diff --git a/packages/app/src/utils/tilePositioning.ts b/packages/app/src/utils/tilePositioning.ts index d75be8072c..826836cb35 100644 --- a/packages/app/src/utils/tilePositioning.ts +++ b/packages/app/src/utils/tilePositioning.ts @@ -5,8 +5,8 @@ import { Tile } from '@/dashboard'; const GRID_COLS = 24; /** - * Generate a unique ID for a tile - * @returns A random string ID in base 36 + * Generate a unique ID for tiles, containers, and tabs. + * Uses two random values concatenated for lower collision risk. */ export const makeId = () => Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); @@ -21,8 +21,8 @@ export const makeId = () => */ export function calculateNextTilePosition( tiles: Tile[], - newW: number = 12, - newH: number = 10, + newW: number, + newH: number, ): { x: number; y: number } { if (tiles.length === 0) { return { x: 0, y: 0 }; From 649bea41e0423b8c393a452fb08465c036707ce0 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 14 Apr 2026 19:03:17 -0700 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20Tab=20delete=20offers=20choice=20?= =?UTF-8?q?=E2=80=94=20delete=20tiles=20or=20move=20to=20another=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback: tab deletion now shows a modal with two options instead of silently merging tiles. Also removes unnecessary comment and adds hook-level tests covering delete/move actions and the legacy dashboard upgrade path. --- packages/app/src/DBDashboardPage.tsx | 16 +- .../app/src/__tests__/GroupContainer.test.tsx | 98 +++--- .../app/src/components/GroupContainer.tsx | 9 +- packages/app/src/components/GroupTabBar.tsx | 92 +++-- .../__tests__/useDashboardContainers.test.tsx | 322 ++++++++++++++++++ .../app/src/hooks/useDashboardContainers.tsx | 36 +- 6 files changed, 456 insertions(+), 117 deletions(-) create mode 100644 packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a507290e84..aa2c0d1cda 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -869,8 +869,6 @@ function downloadObjectAsJson(object: object, fileName = 'output') { downloadAnchorNode.remove(); } -// Extracted component for rendering a single dashboard group/container. -// Eliminates the double-nested inline function rendering pattern. function DashboardGroupItem({ container, containerTiles, @@ -888,7 +886,6 @@ function DashboardGroupItem({ onRenameContainer, onTabChange, dragHandleProps, - confirm, layoutChangeHandler, tileToLayoutItem, renderTileComponent, @@ -905,15 +902,10 @@ function DashboardGroupItem({ onAddTile: (containerId: string, tabId?: string) => void; onAddTab: () => void; onRenameTab: (tabId: string, newTitle: string) => void; - onDeleteTab: (tabId: string) => void; + onDeleteTab: (tabId: string, action: 'delete' | 'move') => void; onRenameContainer: (newTitle: string) => void; onTabChange: (tabId: string) => void; dragHandleProps: DragHandleProps; - confirm: ( - message: React.ReactNode, - confirmLabel?: string, - options?: { variant?: 'primary' | 'danger' }, - ) => Promise; layoutChangeHandler?: (newLayout: RGL.Layout[]) => void; tileToLayoutItem: (tile: Tile) => RGL.Layout; renderTileComponent: (tile: Tile) => React.ReactNode; @@ -942,7 +934,6 @@ function DashboardGroupItem({ onDeleteTab={onDeleteTab} onRename={onRenameContainer} dragHandleProps={dragHandleProps} - confirm={confirm} alertingTabIds={alertingTabIds} > {(currentTabId: string | undefined) => { @@ -2146,8 +2137,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onRenameTab={(tabId, title) => handleRenameTab(container.id, tabId, title) } - onDeleteTab={tabId => - handleDeleteTab(container.id, tabId) + onDeleteTab={(tabId, action) => + handleDeleteTab(container.id, tabId, action) } onRenameContainer={title => handleRenameContainer(container.id, title) @@ -2156,7 +2147,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { handleTabChange(container.id, tabId) } dragHandleProps={dragHandleProps} - confirm={confirm} layoutChangeHandler={containerLayoutChangeHandlers.get( container.id, )} diff --git a/packages/app/src/__tests__/GroupContainer.test.tsx b/packages/app/src/__tests__/GroupContainer.test.tsx index e4b5d1f91f..e05c0f55e5 100644 --- a/packages/app/src/__tests__/GroupContainer.test.tsx +++ b/packages/app/src/__tests__/GroupContainer.test.tsx @@ -192,66 +192,68 @@ describe('GroupContainer', () => { }); 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', - }, + const twoTabProps = { + container: { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'First' }, + { id: 'tab-2', title: 'Second' }, + ], activeTabId: 'tab-1', - onTabChange: jest.fn(), - }); + }, + activeTabId: 'tab-1' as const, + onTabChange: jest.fn(), + }; + + it('opens delete modal when tab delete button is clicked', async () => { + const onDeleteTab = jest.fn(); + renderGroupContainer({ onDeleteTab, ...twoTabProps }); - // 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); + fireEvent.click(screen.getByTestId('tab-delete-tab-1')); - // Wait for async confirm - await screen.findByText('First'); - expect(confirm).toHaveBeenCalledTimes(1); - expect(onDeleteTab).toHaveBeenCalledWith('tab-1'); + expect( + await screen.findByTestId('tab-delete-confirm'), + ).toBeInTheDocument(); + expect(screen.getByTestId('tab-delete-move')).toBeInTheDocument(); }); - it('does not call onDeleteTab when confirm is rejected', async () => { + it('calls onDeleteTab with "delete" when Delete Tab & Tiles is clicked', 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(), - }); + renderGroupContainer({ onDeleteTab, ...twoTabProps }); + + const firstTab = screen.getByRole('tab', { name: 'First' }); + fireEvent.mouseEnter(firstTab); + fireEvent.click(screen.getByTestId('tab-delete-tab-1')); + fireEvent.click(await screen.findByTestId('tab-delete-confirm')); + + expect(onDeleteTab).toHaveBeenCalledWith('tab-1', 'delete'); + }); + + it('calls onDeleteTab with "move" when Move Tiles is clicked', async () => { + const onDeleteTab = jest.fn(); + renderGroupContainer({ onDeleteTab, ...twoTabProps }); + + const firstTab = screen.getByRole('tab', { name: 'First' }); + fireEvent.mouseEnter(firstTab); + fireEvent.click(screen.getByTestId('tab-delete-tab-1')); + fireEvent.click(await screen.findByTestId('tab-delete-move')); + + expect(onDeleteTab).toHaveBeenCalledWith('tab-1', 'move'); + }); + + it('does not call onDeleteTab when cancel is clicked', async () => { + const onDeleteTab = jest.fn(); + renderGroupContainer({ onDeleteTab, ...twoTabProps }); const firstTab = screen.getByRole('tab', { name: 'First' }); fireEvent.mouseEnter(firstTab); - const deleteBtn = screen.getByTestId('tab-delete-tab-1'); - fireEvent.click(deleteBtn); + fireEvent.click(screen.getByTestId('tab-delete-tab-1')); + fireEvent.click(await screen.findByTestId('tab-delete-cancel')); - // Wait a tick for the async confirm to settle - await new Promise(r => setTimeout(r, 0)); - expect(confirm).toHaveBeenCalledTimes(1); expect(onDeleteTab).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx index d08e17b988..72b7b46af9 100644 --- a/packages/app/src/components/GroupContainer.tsx +++ b/packages/app/src/components/GroupContainer.tsx @@ -35,15 +35,10 @@ type GroupContainerProps = { onTabChange?: (tabId: string) => void; onAddTab?: () => void; onRenameTab?: (tabId: string, newTitle: string) => void; - onDeleteTab?: (tabId: string) => void; + onDeleteTab?: (tabId: string, action: 'delete' | 'move') => 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; }; @@ -66,7 +61,6 @@ export default function GroupContainer({ onRename, children, dragHandleProps, - confirm, alertingTabIds, }: GroupContainerProps) { const [isRenamingGroup, setIsRenamingGroup] = useState(false); @@ -282,7 +276,6 @@ export default function GroupContainer({ onDeleteTab={onDeleteTab} containerId={container.id} alertingTabIds={alertingTabIds} - confirm={confirm} hoverControlStyle={hoverControlStyle} /> {addTileButton} diff --git a/packages/app/src/components/GroupTabBar.tsx b/packages/app/src/components/GroupTabBar.tsx index 401a4e72dd..e49487d5dc 100644 --- a/packages/app/src/components/GroupTabBar.tsx +++ b/packages/app/src/components/GroupTabBar.tsx @@ -2,7 +2,10 @@ import { useState } from 'react'; import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, + Button, Flex, + Group, + Modal, Tabs, Text, TextInput, @@ -34,14 +37,9 @@ type GroupTabBarProps = { showControls: boolean; onTabChange?: (tabId: string) => void; onRenameTab?: (tabId: string, newTitle: string) => void; - onDeleteTab?: (tabId: string) => void; + onDeleteTab?: (tabId: string, action: 'delete' | 'move') => void; containerId: string; alertingTabIds?: Set; - confirm?: ( - message: React.ReactNode, - confirmLabel?: string, - options?: { variant?: 'primary' | 'danger' }, - ) => Promise; hoverControlStyle: React.CSSProperties; }; @@ -54,12 +52,12 @@ export default function GroupTabBar({ onDeleteTab, containerId, alertingTabIds, - confirm, hoverControlStyle, }: GroupTabBarProps) { const [renamingTabId, setRenamingTabId] = useState(null); const [tabRenameValue, setTabRenameValue] = useState(''); const [hoveredTabId, setHoveredTabId] = useState(null); + const [deletingTabId, setDeletingTabId] = useState(null); const handleCommitTabRename = (tabId: string) => { const trimmed = tabRenameValue.trim(); @@ -70,24 +68,12 @@ export default function GroupTabBar({ setRenamingTabId(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 deletingTab = deletingTabId + ? tabs.find(t => t.id === deletingTabId) + : null; + const firstRemainingTab = deletingTabId + ? tabs.find(t => t.id !== deletingTabId) + : null; return ( <> @@ -110,7 +96,7 @@ export default function GroupTabBar({ }} onClick={e => { e.stopPropagation(); - handleDeleteTab(tab.id); + setDeletingTabId(tab.id); }} title="Delete tab" data-testid={`tab-delete-${tab.id}`} @@ -197,6 +183,60 @@ export default function GroupTabBar({
)} + {/* Tab delete confirmation modal */} + setDeletingTabId(null)} + centered + withCloseButton={false} + > + + Delete tab{' '} + + {deletingTab?.title ?? 'this tab'} + + ? + + + + {firstRemainingTab && ( + + )} + + + ); } diff --git a/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx b/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx new file mode 100644 index 0000000000..56b380f463 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx @@ -0,0 +1,322 @@ +import { act, renderHook } from '@testing-library/react'; + +import { Dashboard } from '@/dashboard'; + +import useDashboardContainers from '../useDashboardContainers'; + +function makeDashboard(overrides: Partial = {}): Dashboard { + return { + name: 'Test Dashboard', + tiles: [], + containers: [], + ...overrides, + } as Dashboard; +} + +function renderContainersHook(dashboard: Dashboard) { + let current = dashboard; + const setDashboard = jest.fn((d: Dashboard) => { + current = d; + }); + const confirm = jest.fn().mockResolvedValue(true); + const hook = renderHook(() => + useDashboardContainers({ + dashboard: current, + setDashboard, + confirm, + }), + ); + return { hook, setDashboard, confirm, getDashboard: () => current }; +} + +describe('useDashboardContainers', () => { + describe('handleDeleteTab', () => { + const baseDashboard = makeDashboard({ + containers: [ + { + id: 'c1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Tab One' }, + { id: 'tab-2', title: 'Tab Two' }, + { id: 'tab-3', title: 'Tab Three' }, + ], + activeTabId: 'tab-1', + }, + ], + tiles: [ + { id: 't1', containerId: 'c1', tabId: 'tab-1', x: 0, y: 0, w: 6, h: 4 }, + { id: 't2', containerId: 'c1', tabId: 'tab-1', x: 6, y: 0, w: 6, h: 4 }, + { + id: 't3', + containerId: 'c1', + tabId: 'tab-2', + x: 0, + y: 0, + w: 12, + h: 4, + }, + { id: 't4', containerId: 'c1', tabId: 'tab-3', x: 0, y: 0, w: 6, h: 4 }, + ] as Dashboard['tiles'], + }); + + it('action "delete" removes tiles belonging to the deleted tab', () => { + const { hook, getDashboard } = renderContainersHook(baseDashboard); + act(() => { + hook.result.current.handleDeleteTab('c1', 'tab-2', 'delete'); + }); + + const result = getDashboard(); + // tile t3 (tab-2) should be removed + expect(result.tiles.map(t => t.id)).toEqual(['t1', 't2', 't4']); + // tab-2 should be removed from container + expect(result.containers![0].tabs!.map(t => t.id)).toEqual([ + 'tab-1', + 'tab-3', + ]); + }); + + it('action "move" moves tiles to the first remaining tab', () => { + const { hook, getDashboard } = renderContainersHook(baseDashboard); + act(() => { + hook.result.current.handleDeleteTab('c1', 'tab-2', 'move'); + }); + + const result = getDashboard(); + // All tiles should still exist + expect(result.tiles).toHaveLength(4); + // t3 (was tab-2) should now be on tab-1 + const t3 = result.tiles.find(t => t.id === 't3'); + expect(t3?.tabId).toBe('tab-1'); + // tab-2 should be removed + expect(result.containers![0].tabs!.map(t => t.id)).toEqual([ + 'tab-1', + 'tab-3', + ]); + }); + + it('updates activeTabId when deleting the active tab', () => { + const { hook, getDashboard } = renderContainersHook(baseDashboard); + act(() => { + // Delete tab-1 which is the active tab + hook.result.current.handleDeleteTab('c1', 'tab-1', 'delete'); + }); + + const result = getDashboard(); + // activeTabId should switch to the new first tab + expect(result.containers![0].activeTabId).toBe('tab-2'); + }); + + it('syncs container.title to new first tab after deletion', () => { + const { hook, getDashboard } = renderContainersHook(baseDashboard); + act(() => { + // Delete tab-1 (first tab) — container.title should sync to tab-2 + hook.result.current.handleDeleteTab('c1', 'tab-1', 'delete'); + }); + + const result = getDashboard(); + expect(result.containers![0].title).toBe('Tab Two'); + }); + + it('does not affect tiles in other tabs when deleting', () => { + const { hook, getDashboard } = renderContainersHook(baseDashboard); + act(() => { + hook.result.current.handleDeleteTab('c1', 'tab-1', 'delete'); + }); + + const result = getDashboard(); + // t1, t2 (tab-1) deleted; t3 (tab-2), t4 (tab-3) remain + expect(result.tiles.map(t => t.id).sort()).toEqual(['t3', 't4']); + }); + + it('handles deleting a tab with no tiles (delete)', () => { + const emptyTabDashboard = makeDashboard({ + containers: [ + { + id: 'c1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Has Tiles' }, + { id: 'tab-2', title: 'Empty' }, + ], + activeTabId: 'tab-2', + }, + ], + tiles: [ + { + id: 't1', + containerId: 'c1', + tabId: 'tab-1', + x: 0, + y: 0, + w: 6, + h: 4, + }, + ] as Dashboard['tiles'], + }); + + const { hook, getDashboard } = renderContainersHook(emptyTabDashboard); + act(() => { + hook.result.current.handleDeleteTab('c1', 'tab-2', 'delete'); + }); + + const result = getDashboard(); + expect(result.tiles).toHaveLength(1); + expect(result.containers![0].tabs).toHaveLength(1); + expect(result.containers![0].activeTabId).toBe('tab-1'); + }); + + it('handles deleting last of 2 tabs (move)', () => { + const twoTabDashboard = makeDashboard({ + containers: [ + { + id: 'c1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Keep' }, + { id: 'tab-2', title: 'Remove' }, + ], + activeTabId: 'tab-2', + }, + ], + tiles: [ + { + id: 't1', + containerId: 'c1', + tabId: 'tab-1', + x: 0, + y: 0, + w: 6, + h: 4, + }, + { + id: 't2', + containerId: 'c1', + tabId: 'tab-2', + x: 0, + y: 0, + w: 6, + h: 4, + }, + ] as Dashboard['tiles'], + }); + + const { hook, getDashboard } = renderContainersHook(twoTabDashboard); + act(() => { + hook.result.current.handleDeleteTab('c1', 'tab-2', 'move'); + }); + + const result = getDashboard(); + expect(result.tiles).toHaveLength(2); + // t2 should now be on tab-1 + expect(result.tiles.find(t => t.id === 't2')?.tabId).toBe('tab-1'); + // Only 1 tab remaining + expect(result.containers![0].tabs).toHaveLength(1); + expect(result.containers![0].title).toBe('Keep'); + }); + }); + + describe('legacy dashboard upgrade path', () => { + // Simulates a dashboard stored in MongoDB before the unified-group changes: + // containers have `type: 'section'`, no tabs/tabId fields, no collapsible/bordered. + const legacyDashboard = makeDashboard({ + containers: [ + { + id: 'section-1', + title: 'Infrastructure', + collapsed: false, + }, + { + id: 'section-2', + title: 'Application', + collapsed: true, + }, + ], + tiles: [ + { + id: 't1', + containerId: 'section-1', + x: 0, + y: 0, + w: 12, + h: 4, + }, + { + id: 't2', + containerId: 'section-1', + x: 0, + y: 4, + w: 6, + h: 4, + }, + { + id: 't3', + containerId: 'section-2', + x: 0, + y: 0, + w: 8, + h: 6, + }, + ] as Dashboard['tiles'], + }); + + it('handleAddTab creates 2 tabs and assigns existing tiles to tab 1', () => { + const { hook, getDashboard } = renderContainersHook(legacyDashboard); + act(() => { + hook.result.current.handleAddTab('section-1'); + }); + + const result = getDashboard(); + const container = result.containers![0]; + expect(container.tabs).toHaveLength(2); + // Existing tiles assigned to first tab + const sectionTiles = result.tiles.filter( + t => t.containerId === 'section-1', + ); + expect(sectionTiles.every(t => t.tabId === container.tabs![0].id)).toBe( + true, + ); + }); + + it('handleRenameContainer works on legacy container', () => { + const { hook, getDashboard } = renderContainersHook(legacyDashboard); + act(() => { + hook.result.current.handleRenameContainer('section-1', 'New Name'); + }); + + const result = getDashboard(); + expect(result.containers![0].title).toBe('New Name'); + }); + + it('handleToggleCollapsed works on legacy container', () => { + const { hook, getDashboard } = renderContainersHook(legacyDashboard); + act(() => { + hook.result.current.handleToggleCollapsed('section-2'); + }); + + const result = getDashboard(); + // Was true, now false + expect(result.containers![1].collapsed).toBe(false); + }); + + it('handleDeleteContainer ungroups tiles from legacy container', async () => { + const { hook, getDashboard } = renderContainersHook(legacyDashboard); + await act(async () => { + await hook.result.current.handleDeleteContainer('section-1'); + }); + + const result = getDashboard(); + // Container removed + expect(result.containers).toHaveLength(1); + expect(result.containers![0].id).toBe('section-2'); + // Tiles from section-1 are ungrouped + const formerTiles = result.tiles.filter( + t => t.id === 't1' || t.id === 't2', + ); + expect(formerTiles.every(t => t.containerId === undefined)).toBe(true); + }); + }); +}); diff --git a/packages/app/src/hooks/useDashboardContainers.tsx b/packages/app/src/hooks/useDashboardContainers.tsx index f25811ffff..ce63f3fc28 100644 --- a/packages/app/src/hooks/useDashboardContainers.tsx +++ b/packages/app/src/hooks/useDashboardContainers.tsx @@ -228,7 +228,7 @@ export default function useDashboardContainers({ ); const handleDeleteTab = useCallback( - (containerId: string, tabId: string) => { + (containerId: string, tabId: string, action: 'delete' | 'move') => { if (!dashboard) return; const container = dashboard.containers?.find(c => c.id === containerId); if (!container?.tabs) return; @@ -239,34 +239,26 @@ export default function useDashboardContainers({ 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; - } - } + if (action === 'delete') { + draft.tiles = draft.tiles.filter( + t => !(t.containerId === containerId && t.tabId === tabId), + ); } else { - const targetTabId = remaining[0].id; - // Move tiles from deleted tab to first remaining tab + // Move tiles to first remaining tab + const targetTabId = remaining[0]?.id; 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; } + + c.tabs = c.tabs.filter(t => t.id !== tabId); + const firstTab = c.tabs[0]; + if (c.activeTabId === tabId) { + c.activeTabId = firstTab?.id; + } + if (firstTab) c.title = firstTab.title; }), ); },