feat: 3-option prompt for group delete (Cancel / Ungroup Tiles / Delete)

Matches the tab-delete pattern: deleting a group now opens a modal letting
the user choose whether to preserve tiles (ungroup to top level) or delete
them along with the group.

Uniform across plain groups, legacy sections, and multi-tab groups — the
filter operates on container.id and handles all tiles regardless of their
tab assignment.

- Ungroup Tiles (primary, only offered when tileCount > 0): strips
  containerId + tabId, shifts tiles below any existing ungrouped tiles
- Delete Group & Tiles (danger): removes tiles belonging to this container
- Cancel: no-op

Removed the `confirm` prop from useDashboardContainers since the hook no
longer prompts; callers render their own modal.
This commit is contained in:
Alex Fedotyev 2026-04-20 20:56:32 -07:00
parent 9c95f42ee5
commit b845595dce
5 changed files with 244 additions and 69 deletions

View file

@ -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={() => {

View file

@ -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({

View file

@ -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}`}
>
<IconDotsVertical size={14} />
</ActionIcon>
@ -196,7 +204,8 @@ export default function DashboardContainer({
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={onDelete}
onClick={() => setDeleteModalOpen(true)}
data-testid={`group-delete-${container.id}`}
>
Delete Group
</Menu.Item>
@ -373,6 +382,58 @@ export default function DashboardContainer({
{!isCollapsed && (
<Box>{children(hasTabs ? resolvedActiveTabId : undefined)}</Box>
)}
<Modal
data-testid="group-delete-modal"
opened={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
centered
withCloseButton={false}
>
<Text size="sm" opacity={0.7}>
Delete{' '}
<Text component="span" fw={700}>
{headerTitle}
</Text>
?
{tileCount > 0
? ` This group contains ${tileCount} tile${tileCount > 1 ? 's' : ''}.`
: ''}
</Text>
<Group justify="flex-end" mt="md" gap="xs">
<Button
data-testid="group-delete-cancel"
size="xs"
variant="secondary"
onClick={() => setDeleteModalOpen(false)}
>
Cancel
</Button>
{tileCount > 0 && (
<Button
data-testid="group-delete-ungroup"
size="xs"
variant="primary"
onClick={() => {
onDelete?.('ungroup');
setDeleteModalOpen(false);
}}
>
Ungroup Tiles
</Button>
)}
<Button
data-testid="group-delete-confirm"
size="xs"
variant="danger"
onClick={() => {
onDelete?.('delete');
setDeleteModalOpen(false);
}}
>
{tileCount > 0 ? 'Delete Group & Tiles' : 'Delete Group'}
</Button>
</Group>
</Modal>
</Box>
);
}

View file

@ -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);
});
});
});

View file

@ -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<boolean>;
// 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{' '}
<Text component="span" fw={700}>
{label}
</Text>
?{' '}
{`${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`}
</>
) : (
<>
Delete{' '}
<Text component="span" fw={700}>
{label}
</Text>
?
</>
);
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(