mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
9c95f42ee5
commit
b845595dce
5 changed files with 244 additions and 69 deletions
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue