Skip to content

Commit 422db99

Browse files
committed
feat: Extract container and tile selection hooks
useDashboardContainers (263 lines): container CRUD + tab management. Uses shared makeId from tilePositioning utility. useTileSelection (76 lines): Shift+click selection + Cmd+G grouping. Cmd+G creates 'section' type (collapsible, most common container).
1 parent 6aa19dc commit 422db99

2 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { useCallback } from 'react';
2+
import produce from 'immer';
3+
import { arrayMove } from '@dnd-kit/sortable';
4+
import { Text } from '@mantine/core';
5+
6+
import { Dashboard } from '@/dashboard';
7+
import { makeId } from '@/utils/tilePositioning';
8+
9+
type ConfirmFn = (
10+
message: React.ReactNode,
11+
confirmLabel?: string,
12+
options?: { variant?: 'primary' | 'danger' },
13+
) => Promise<boolean>;
14+
15+
export default function useDashboardContainers({
16+
dashboard,
17+
setDashboard,
18+
confirm,
19+
}: {
20+
dashboard: Dashboard | undefined;
21+
setDashboard: (dashboard: Dashboard) => void;
22+
confirm: ConfirmFn;
23+
}) {
24+
const handleAddContainer = useCallback(
25+
(type: 'section' | 'tab' | 'group' = 'section') => {
26+
if (!dashboard) return;
27+
const titles: Record<string, string> = {
28+
section: 'New Section',
29+
tab: 'New Tab Container',
30+
group: 'New Group',
31+
};
32+
setDashboard(
33+
produce(dashboard, draft => {
34+
if (!draft.containers) draft.containers = [];
35+
const containerId = makeId();
36+
draft.containers.push({
37+
id: containerId,
38+
type,
39+
title: titles[type],
40+
collapsed: false,
41+
});
42+
// Tab containers get an initial child tab
43+
if (type === 'tab') {
44+
const firstTabId = makeId();
45+
draft.containers.push({
46+
id: firstTabId,
47+
type: 'tab',
48+
title: 'Tab 1',
49+
collapsed: false,
50+
parentId: containerId,
51+
});
52+
// Set the initial active tab
53+
const parent = draft.containers.find(c => c.id === containerId);
54+
if (parent) parent.activeTabId = firstTabId;
55+
}
56+
}),
57+
);
58+
},
59+
[dashboard, setDashboard],
60+
);
61+
62+
// Intentionally persists collapsed state to the server via setDashboard
63+
// (same pattern as tile drag/resize). This matches Grafana and Kibana
64+
// behavior where collapsed state is saved with the dashboard for all viewers.
65+
const handleToggleSection = useCallback(
66+
(containerId: string) => {
67+
if (!dashboard) return;
68+
setDashboard(
69+
produce(dashboard, draft => {
70+
const section = draft.containers?.find(s => s.id === containerId);
71+
if (section) section.collapsed = !section.collapsed;
72+
}),
73+
);
74+
},
75+
[dashboard, setDashboard],
76+
);
77+
78+
const handleRenameSection = useCallback(
79+
(containerId: string, newTitle: string) => {
80+
if (!dashboard || !newTitle.trim()) return;
81+
setDashboard(
82+
produce(dashboard, draft => {
83+
const section = draft.containers?.find(s => s.id === containerId);
84+
if (section) section.title = newTitle.trim();
85+
}),
86+
);
87+
},
88+
[dashboard, setDashboard],
89+
);
90+
91+
const handleDeleteSection = useCallback(
92+
async (containerId: string) => {
93+
if (!dashboard) return;
94+
const container = dashboard.containers?.find(c => c.id === containerId);
95+
const tileCount = dashboard.tiles.filter(
96+
t => t.containerId === containerId,
97+
).length;
98+
const label = container?.title ?? 'this section';
99+
100+
const confirmed = await confirm(
101+
<>
102+
Delete{' '}
103+
<Text component="span" fw={700}>
104+
{label}
105+
</Text>
106+
?
107+
{tileCount > 0 &&
108+
` ${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`}
109+
</>,
110+
'Delete',
111+
{ variant: 'danger' },
112+
);
113+
if (!confirmed) return;
114+
115+
setDashboard(
116+
produce(dashboard, draft => {
117+
// Collect IDs to delete: the container + any child tabs
118+
const childIds = new Set(
119+
(draft.containers ?? [])
120+
.filter(c => c.parentId === containerId)
121+
.map(c => c.id),
122+
);
123+
const idsToDelete = new Set([containerId, ...childIds]);
124+
125+
const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
126+
let maxUngroupedY = 0;
127+
for (const tile of draft.tiles) {
128+
if (!tile.containerId || !allSectionIds.has(tile.containerId)) {
129+
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
130+
}
131+
}
132+
133+
for (const tile of draft.tiles) {
134+
if (tile.containerId && idsToDelete.has(tile.containerId)) {
135+
tile.y += maxUngroupedY;
136+
delete tile.containerId;
137+
}
138+
}
139+
140+
draft.containers = draft.containers?.filter(
141+
s => !idsToDelete.has(s.id),
142+
);
143+
}),
144+
);
145+
},
146+
[dashboard, setDashboard, confirm],
147+
);
148+
149+
const handleReorderSections = useCallback(
150+
(fromIndex: number, toIndex: number) => {
151+
if (!dashboard?.containers) return;
152+
setDashboard(
153+
produce(dashboard, draft => {
154+
if (draft.containers) {
155+
draft.containers = arrayMove(draft.containers, fromIndex, toIndex);
156+
}
157+
}),
158+
);
159+
},
160+
[dashboard, setDashboard],
161+
);
162+
163+
// --- Tab management ---
164+
165+
const handleAddTab = useCallback(
166+
(parentId: string) => {
167+
if (!dashboard) return;
168+
const siblings =
169+
dashboard.containers?.filter(c => c.parentId === parentId) ?? [];
170+
const newTabId = makeId();
171+
setDashboard(
172+
produce(dashboard, draft => {
173+
if (!draft.containers) draft.containers = [];
174+
draft.containers.push({
175+
id: newTabId,
176+
type: 'tab',
177+
title: `Tab ${siblings.length + 1}`,
178+
collapsed: false,
179+
parentId,
180+
});
181+
// Auto-switch to the new tab
182+
const parent = draft.containers.find(c => c.id === parentId);
183+
if (parent) parent.activeTabId = newTabId;
184+
}),
185+
);
186+
},
187+
[dashboard, setDashboard],
188+
);
189+
190+
const handleRenameTab = useCallback(
191+
(tabId: string, newTitle: string) => {
192+
if (!dashboard || !newTitle.trim()) return;
193+
setDashboard(
194+
produce(dashboard, draft => {
195+
const tab = draft.containers?.find(c => c.id === tabId);
196+
if (tab) tab.title = newTitle.trim();
197+
}),
198+
);
199+
},
200+
[dashboard, setDashboard],
201+
);
202+
203+
const handleDeleteTab = useCallback(
204+
(tabId: string) => {
205+
if (!dashboard) return;
206+
const tab = dashboard.containers?.find(c => c.id === tabId);
207+
if (!tab?.parentId) return;
208+
const parentId = tab.parentId;
209+
const siblings =
210+
dashboard.containers?.filter(
211+
c => c.parentId === parentId && c.id !== tabId,
212+
) ?? [];
213+
// Don't delete the last tab
214+
if (siblings.length === 0) return;
215+
216+
setDashboard(
217+
produce(dashboard, draft => {
218+
// Move tiles from deleted tab to first remaining sibling
219+
const targetId = siblings[0].id;
220+
for (const tile of draft.tiles) {
221+
if (tile.containerId === tabId) {
222+
tile.containerId = targetId;
223+
}
224+
}
225+
// Remove the tab
226+
draft.containers = draft.containers?.filter(c => c.id !== tabId);
227+
// Update parent's activeTabId if it pointed to deleted tab
228+
const parent = draft.containers?.find(c => c.id === parentId);
229+
if (parent?.activeTabId === tabId) {
230+
parent.activeTabId = targetId;
231+
}
232+
}),
233+
);
234+
},
235+
[dashboard, setDashboard],
236+
);
237+
238+
const handleTabChange = useCallback(
239+
(parentId: string, tabId: string) => {
240+
if (!dashboard) return;
241+
setDashboard(
242+
produce(dashboard, draft => {
243+
const parent = draft.containers?.find(c => c.id === parentId);
244+
if (parent) parent.activeTabId = tabId;
245+
}),
246+
);
247+
},
248+
[dashboard, setDashboard],
249+
);
250+
251+
return {
252+
handleAddContainer,
253+
handleToggleSection,
254+
handleRenameSection,
255+
handleDeleteSection,
256+
handleReorderSections,
257+
handleAddTab,
258+
handleRenameTab,
259+
handleDeleteTab,
260+
handleTabChange,
261+
};
262+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useCallback, useState } from 'react';
2+
import produce from 'immer';
3+
import { useHotkeys } from '@mantine/hooks';
4+
5+
import { Dashboard } from '@/dashboard';
6+
import { makeId } from '@/utils/tilePositioning';
7+
8+
export default function useTileSelection({
9+
dashboard,
10+
setDashboard,
11+
}: {
12+
dashboard: Dashboard | undefined;
13+
setDashboard: (dashboard: Dashboard) => void;
14+
}) {
15+
const [selectedTileIds, setSelectedTileIds] = useState<Set<string>>(
16+
new Set(),
17+
);
18+
19+
const handleTileSelect = useCallback((tileId: string, shiftKey: boolean) => {
20+
if (!shiftKey) return;
21+
setSelectedTileIds(prev => {
22+
const next = new Set(prev);
23+
if (next.has(tileId)) next.delete(tileId);
24+
else next.add(tileId);
25+
return next;
26+
});
27+
}, []);
28+
29+
// Creates a 'section' type container (not 'group') intentionally.
30+
// Sections are collapsible and are the most common container type for
31+
// organizing tiles on a dashboard. The function name reflects the user
32+
// action (grouping selected tiles) rather than the container type created.
33+
const handleGroupSelected = useCallback(() => {
34+
if (!dashboard || selectedTileIds.size === 0) return;
35+
const groupId = makeId();
36+
setDashboard(
37+
produce(dashboard, draft => {
38+
if (!draft.containers) draft.containers = [];
39+
draft.containers.push({
40+
id: groupId,
41+
type: 'section',
42+
title: 'New Section',
43+
collapsed: false,
44+
});
45+
for (const tile of draft.tiles) {
46+
if (selectedTileIds.has(tile.id)) {
47+
tile.containerId = groupId;
48+
}
49+
}
50+
}),
51+
);
52+
setSelectedTileIds(new Set());
53+
}, [dashboard, selectedTileIds, setDashboard]);
54+
55+
// Cmd+G / Ctrl+G to group selected tiles
56+
useHotkeys([
57+
[
58+
'mod+g',
59+
e => {
60+
e.preventDefault();
61+
handleGroupSelected();
62+
},
63+
],
64+
// Escape to clear selection
65+
['escape', () => setSelectedTileIds(new Set())],
66+
]);
67+
68+
return {
69+
selectedTileIds,
70+
setSelectedTileIds,
71+
handleTileSelect,
72+
handleGroupSelected,
73+
};
74+
}

0 commit comments

Comments
 (0)