diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a1d5b8c0..b5737650 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -936,7 +936,7 @@ function DashboardContainerRow({ onToggleDefaultCollapsed: () => void; onToggleCollapsible: () => void; onToggleBordered: () => void; - onDeleteContainer: () => void; + onDeleteContainer: (action: 'ungroup' | 'delete') => void; onAddTile: (containerId: string, tabId?: string) => void; onAddTab: () => void; onRenameTab: (tabId: string, newTitle: string) => void; @@ -961,6 +961,7 @@ function DashboardContainerRow({ onToggleCollapsible={onToggleCollapsible} onToggleBordered={onToggleBordered} onDelete={onDeleteContainer} + tileCount={containerTiles.length} onAddTile={() => onAddTile(container.id, hasTabs ? activeTabId : undefined) } @@ -1687,7 +1688,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { handleAddTab, handleRenameTab, handleDeleteTab, - } = useDashboardContainers({ dashboard, setDashboard, confirm }); + } = useDashboardContainers({ dashboard, setDashboard }); const onAddTile = (containerId?: string, tabId?: string) => { // Auto-expand collapsed container via URL state so the new tile is visible @@ -2239,8 +2240,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onToggleBordered={() => handleToggleBordered(container.id) } - onDeleteContainer={() => - handleDeleteContainer(container.id) + onDeleteContainer={action => + handleDeleteContainer(container.id, action) } onAddTile={onAddTile} onAddTab={() => { diff --git a/packages/app/src/__tests__/DashboardContainer.test.tsx b/packages/app/src/__tests__/DashboardContainer.test.tsx index d7f4689e..9740a97e 100644 --- a/packages/app/src/__tests__/DashboardContainer.test.tsx +++ b/packages/app/src/__tests__/DashboardContainer.test.tsx @@ -258,6 +258,98 @@ describe('DashboardContainer', () => { }); }); + describe('group delete prompt', () => { + const baseContainer = { + id: 'g1', + title: 'My Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'My Group' }], + }; + + it('opens the delete modal when "Delete Group" menu item is clicked', async () => { + renderDashboardContainer({ + onDelete: jest.fn(), + tileCount: 2, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + expect( + await screen.findByTestId('group-delete-modal'), + ).toBeInTheDocument(); + }); + + it('offers Ungroup + Delete when tileCount > 0', async () => { + renderDashboardContainer({ + onDelete: jest.fn(), + tileCount: 3, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + expect( + await screen.findByTestId('group-delete-ungroup'), + ).toBeInTheDocument(); + expect(screen.getByTestId('group-delete-confirm')).toBeInTheDocument(); + expect(screen.getByTestId('group-delete-cancel')).toBeInTheDocument(); + }); + + it('hides Ungroup option when tileCount is 0', async () => { + renderDashboardContainer({ + onDelete: jest.fn(), + tileCount: 0, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + expect( + screen.queryByTestId('group-delete-ungroup'), + ).not.toBeInTheDocument(); + expect( + await screen.findByTestId('group-delete-confirm'), + ).toBeInTheDocument(); + }); + + it('calls onDelete with "ungroup" when Ungroup Tiles is clicked', async () => { + const onDelete = jest.fn(); + renderDashboardContainer({ + onDelete, + tileCount: 2, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + fireEvent.click(await screen.findByTestId('group-delete-ungroup')); + expect(onDelete).toHaveBeenCalledWith('ungroup'); + }); + + it('calls onDelete with "delete" when Delete Group & Tiles is clicked', async () => { + const onDelete = jest.fn(); + renderDashboardContainer({ + onDelete, + tileCount: 2, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + fireEvent.click(await screen.findByTestId('group-delete-confirm')); + expect(onDelete).toHaveBeenCalledWith('delete'); + }); + + it('does not call onDelete when Cancel is clicked', async () => { + const onDelete = jest.fn(); + renderDashboardContainer({ + onDelete, + tileCount: 2, + container: baseContainer, + }); + fireEvent.click(screen.getByTestId('group-menu-g1')); + fireEvent.click(await screen.findByTestId('group-delete-g1')); + fireEvent.click(await screen.findByTestId('group-delete-cancel')); + expect(onDelete).not.toHaveBeenCalled(); + }); + }); + describe('alert indicators', () => { it('shows alert dot on collapsed group header when alertingTabIds is non-empty', () => { const { container: wrapper } = renderDashboardContainer({ diff --git a/packages/app/src/components/DashboardContainer.tsx b/packages/app/src/components/DashboardContainer.tsx index a670f0cd..2859bfcc 100644 --- a/packages/app/src/components/DashboardContainer.tsx +++ b/packages/app/src/components/DashboardContainer.tsx @@ -3,8 +3,11 @@ import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common- import { ActionIcon, Box, + Button, Flex, + Group, Menu, + Modal, Tabs, Text, TextInput, @@ -29,7 +32,9 @@ type DashboardContainerProps = { onToggleDefaultCollapsed?: () => void; onToggleCollapsible?: () => void; onToggleBordered?: () => void; - onDelete?: () => void; + onDelete?: (action: 'ungroup' | 'delete') => void; + /** Tile count inside this container — determines whether "Ungroup Tiles" is offered. */ + tileCount?: number; onAddTile?: () => void; activeTabId?: string; onTabChange?: (tabId: string) => void; @@ -52,6 +57,7 @@ export default function DashboardContainer({ onToggleCollapsible, onToggleBordered, onDelete, + tileCount = 0, onAddTile, activeTabId, onTabChange, @@ -67,6 +73,7 @@ export default function DashboardContainer({ const [groupRenameValue, setGroupRenameValue] = useState(container.title); const [hovered, setHovered] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const tabs = container.tabs ?? []; const hasTabs = tabs.length >= 2; @@ -149,6 +156,7 @@ export default function DashboardContainer({ size="sm" tabIndex={showControls ? 0 : -1} style={hoverControlStyle} + data-testid={`group-menu-${container.id}`} > @@ -196,7 +204,8 @@ export default function DashboardContainer({ } color="red" - onClick={onDelete} + onClick={() => setDeleteModalOpen(true)} + data-testid={`group-delete-${container.id}`} > Delete Group @@ -373,6 +382,58 @@ export default function DashboardContainer({ {!isCollapsed && ( {children(hasTabs ? resolvedActiveTabId : undefined)} )} + setDeleteModalOpen(false)} + centered + withCloseButton={false} + > + + Delete{' '} + + {headerTitle} + + ? + {tileCount > 0 + ? ` This group contains ${tileCount} tile${tileCount > 1 ? 's' : ''}.` + : ''} + + + + {tileCount > 0 && ( + + )} + + + ); } diff --git a/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx b/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx index 56b380f4..73e5a59d 100644 --- a/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardContainers.test.tsx @@ -18,15 +18,13 @@ function renderContainersHook(dashboard: 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 }; + return { hook, setDashboard, getDashboard: () => current }; } describe('useDashboardContainers', () => { @@ -302,21 +300,78 @@ describe('useDashboardContainers', () => { expect(result.containers![1].collapsed).toBe(false); }); - it('handleDeleteContainer ungroups tiles from legacy container', async () => { + it('handleDeleteContainer action="ungroup" moves tiles from legacy container to top level', () => { const { hook, getDashboard } = renderContainersHook(legacyDashboard); - await act(async () => { - await hook.result.current.handleDeleteContainer('section-1'); + act(() => { + hook.result.current.handleDeleteContainer('section-1', 'ungroup'); }); 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); + expect(formerTiles.every(t => t.tabId === undefined)).toBe(true); + }); + + it('handleDeleteContainer action="delete" removes tiles from legacy container', () => { + const { hook, getDashboard } = renderContainersHook(legacyDashboard); + act(() => { + hook.result.current.handleDeleteContainer('section-1', 'delete'); + }); + + const result = getDashboard(); + expect(result.containers).toHaveLength(1); + expect(result.tiles.find(t => t.id === 't1')).toBeUndefined(); + expect(result.tiles.find(t => t.id === 't2')).toBeUndefined(); + }); + }); + + describe('handleDeleteContainer — multi-tab group', () => { + const multiTabDashboard = makeDashboard({ + containers: [ + { + id: 'g1', + title: 'Group', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + activeTabId: 'tab-a', + }, + ], + tiles: [ + { id: 't1', containerId: 'g1', tabId: 'tab-a', x: 0, y: 0, w: 6, h: 4 }, + { id: 't2', containerId: 'g1', tabId: 'tab-a', x: 6, y: 0, w: 6, h: 4 }, + { id: 't3', containerId: 'g1', tabId: 'tab-b', x: 0, y: 0, w: 6, h: 4 }, + ] as Dashboard['tiles'], + }); + + it('action="ungroup" strips containerId and tabId from all tiles across all tabs', () => { + const { hook, getDashboard } = renderContainersHook(multiTabDashboard); + act(() => { + hook.result.current.handleDeleteContainer('g1', 'ungroup'); + }); + + const result = getDashboard(); + expect(result.containers).toHaveLength(0); + expect(result.tiles).toHaveLength(3); + expect(result.tiles.every(t => t.containerId === undefined)).toBe(true); + expect(result.tiles.every(t => t.tabId === undefined)).toBe(true); + }); + + it('action="delete" removes all tiles across all tabs', () => { + const { hook, getDashboard } = renderContainersHook(multiTabDashboard); + act(() => { + hook.result.current.handleDeleteContainer('g1', 'delete'); + }); + + const result = getDashboard(); + expect(result.containers).toHaveLength(0); + expect(result.tiles).toHaveLength(0); }); }); }); diff --git a/packages/app/src/hooks/useDashboardContainers.tsx b/packages/app/src/hooks/useDashboardContainers.tsx index 2df25bdc..91e97ce6 100644 --- a/packages/app/src/hooks/useDashboardContainers.tsx +++ b/packages/app/src/hooks/useDashboardContainers.tsx @@ -1,17 +1,10 @@ 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; - // 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` @@ -22,11 +15,9 @@ type ConfirmFn = ( export default function useDashboardContainers({ dashboard, setDashboard, - confirm, }: { dashboard: Dashboard | undefined; setDashboard: (dashboard: Dashboard) => void; - confirm: ConfirmFn; }) { const handleAddContainer = useCallback(() => { if (!dashboard) return; @@ -78,56 +69,31 @@ export default function useDashboardContainers({ ); const handleDeleteContainer = useCallback( - async (containerId: string) => { + (containerId: string, action: 'ungroup' | 'delete') => { 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); + if (action === 'delete') { + draft.tiles = draft.tiles.filter( + t => t.containerId !== containerId, + ); + } else { + 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; + for (const tile of draft.tiles) { + if (tile.containerId === containerId) { + tile.y += maxUngroupedY; + delete tile.containerId; + delete tile.tabId; + } } } @@ -137,7 +103,7 @@ export default function useDashboardContainers({ }), ); }, - [dashboard, setDashboard, confirm], + [dashboard, setDashboard], ); const handleReorderContainers = useCallback(