Skip to content

Commit 45ddd7c

Browse files
committed
feat: Extract container and tile selection hooks
useDashboardContainers (270 lines): all container handlers. useTileSelection (71 lines): Shift+click + Cmd+G grouping.
1 parent d0ea131 commit 45ddd7c

2 files changed

Lines changed: 341 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)