Skip to content

Commit 61ac523

Browse files
committed
feat: Extract container and tile selection hooks
Groups created with 1 tab. Add Tab adds 2nd. Delete to 1 keeps tab. Header rename syncs tabs[0]. Always-confirm delete.
1 parent 384e858 commit 61ac523

2 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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' | 'group' = 'section') => {
26+
if (!dashboard) return;
27+
const titles: Record<string, string> = {
28+
section: 'New Section',
29+
group: 'New Group',
30+
};
31+
setDashboard(
32+
produce(dashboard, draft => {
33+
if (!draft.containers) draft.containers = [];
34+
const containerId = makeId();
35+
if (type === 'group') {
36+
const tabId = makeId();
37+
draft.containers.push({
38+
id: containerId,
39+
type,
40+
title: titles[type],
41+
collapsed: false,
42+
tabs: [{ id: tabId, title: titles[type] }],
43+
activeTabId: tabId,
44+
});
45+
} else {
46+
draft.containers.push({
47+
id: containerId,
48+
type,
49+
title: titles[type],
50+
collapsed: false,
51+
});
52+
}
53+
}),
54+
);
55+
},
56+
[dashboard, setDashboard],
57+
);
58+
59+
// Intentionally persists collapsed state to the server via setDashboard
60+
// (same pattern as tile drag/resize). This matches Grafana and Kibana
61+
// behavior where collapsed state is saved with the dashboard for all viewers.
62+
const handleToggleSection = useCallback(
63+
(containerId: string) => {
64+
if (!dashboard) return;
65+
setDashboard(
66+
produce(dashboard, draft => {
67+
const section = draft.containers?.find(s => s.id === containerId);
68+
if (section) section.collapsed = !section.collapsed;
69+
}),
70+
);
71+
},
72+
[dashboard, setDashboard],
73+
);
74+
75+
const handleRenameSection = useCallback(
76+
(containerId: string, newTitle: string) => {
77+
if (!dashboard || !newTitle.trim()) return;
78+
setDashboard(
79+
produce(dashboard, draft => {
80+
const section = draft.containers?.find(s => s.id === containerId);
81+
if (section) {
82+
section.title = newTitle.trim();
83+
// For groups with 1 tab, sync tabs[0].title (they share the header)
84+
if (section.type === 'group' && section.tabs?.length === 1) {
85+
section.tabs[0].title = newTitle.trim();
86+
}
87+
}
88+
}),
89+
);
90+
},
91+
[dashboard, setDashboard],
92+
);
93+
94+
const handleDeleteSection = useCallback(
95+
async (containerId: string) => {
96+
if (!dashboard) return;
97+
const container = dashboard.containers?.find(c => c.id === containerId);
98+
const tileCount = dashboard.tiles.filter(
99+
t => t.containerId === containerId,
100+
).length;
101+
const label = container?.title ?? 'this section';
102+
103+
const message =
104+
tileCount > 0 ? (
105+
<>
106+
Delete{' '}
107+
<Text component="span" fw={700}>
108+
{label}
109+
</Text>
110+
?{' '}
111+
{`${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`}
112+
</>
113+
) : (
114+
<>
115+
Delete{' '}
116+
<Text component="span" fw={700}>
117+
{label}
118+
</Text>
119+
?
120+
</>
121+
);
122+
123+
const confirmed = await confirm(message, 'Delete', {
124+
variant: 'danger',
125+
});
126+
if (!confirmed) return;
127+
128+
setDashboard(
129+
produce(dashboard, draft => {
130+
const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
131+
let maxUngroupedY = 0;
132+
for (const tile of draft.tiles) {
133+
if (!tile.containerId || !allSectionIds.has(tile.containerId)) {
134+
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
135+
}
136+
}
137+
138+
for (const tile of draft.tiles) {
139+
if (tile.containerId === containerId) {
140+
tile.y += maxUngroupedY;
141+
delete tile.containerId;
142+
delete tile.tabId;
143+
}
144+
}
145+
146+
draft.containers = draft.containers?.filter(
147+
s => s.id !== containerId,
148+
);
149+
}),
150+
);
151+
},
152+
[dashboard, setDashboard, confirm],
153+
);
154+
155+
const handleReorderSections = useCallback(
156+
(fromIndex: number, toIndex: number) => {
157+
if (!dashboard?.containers) return;
158+
setDashboard(
159+
produce(dashboard, draft => {
160+
if (draft.containers) {
161+
draft.containers = arrayMove(draft.containers, fromIndex, toIndex);
162+
}
163+
}),
164+
);
165+
},
166+
[dashboard, setDashboard],
167+
);
168+
169+
// --- Tab management ---
170+
171+
const handleAddTab = useCallback(
172+
(containerId: string) => {
173+
if (!dashboard) return;
174+
const container = dashboard.containers?.find(c => c.id === containerId);
175+
if (!container) return;
176+
const existingTabs = container.tabs ?? [];
177+
178+
setDashboard(
179+
produce(dashboard, draft => {
180+
const c = draft.containers?.find(c => c.id === containerId);
181+
if (!c) return;
182+
183+
if (existingTabs.length === 1) {
184+
// Group already has 1 tab (the default); just add a second tab
185+
const newTabId = makeId();
186+
if (!c.tabs) c.tabs = [];
187+
c.tabs.push({ id: newTabId, title: 'New Tab' });
188+
c.activeTabId = newTabId;
189+
// Ensure existing tiles are assigned to the first tab
190+
const firstTabId = existingTabs[0].id;
191+
for (const tile of draft.tiles) {
192+
if (tile.containerId === containerId && !tile.tabId) {
193+
tile.tabId = firstTabId;
194+
}
195+
}
196+
} else if (existingTabs.length === 0) {
197+
// Legacy group with no tabs: create 2 tabs
198+
const tab1Id = makeId();
199+
const tab2Id = makeId();
200+
c.tabs = [
201+
{ id: tab1Id, title: 'Tab 1' },
202+
{ id: tab2Id, title: 'Tab 2' },
203+
];
204+
c.activeTabId = tab1Id;
205+
for (const tile of draft.tiles) {
206+
if (tile.containerId === containerId) {
207+
tile.tabId = tab1Id;
208+
}
209+
}
210+
} else {
211+
// Already has 2+ tabs, add one more
212+
if (!c.tabs) c.tabs = [];
213+
const newTabId = makeId();
214+
c.tabs.push({
215+
id: newTabId,
216+
title: `Tab ${existingTabs.length + 1}`,
217+
});
218+
c.activeTabId = newTabId;
219+
}
220+
}),
221+
);
222+
},
223+
[dashboard, setDashboard],
224+
);
225+
226+
const handleRenameTab = useCallback(
227+
(containerId: string, tabId: string, newTitle: string) => {
228+
if (!dashboard || !newTitle.trim()) return;
229+
setDashboard(
230+
produce(dashboard, draft => {
231+
const container = draft.containers?.find(c => c.id === containerId);
232+
const tab = container?.tabs?.find(t => t.id === tabId);
233+
if (tab) tab.title = newTitle.trim();
234+
}),
235+
);
236+
},
237+
[dashboard, setDashboard],
238+
);
239+
240+
const handleDeleteTab = useCallback(
241+
(containerId: string, tabId: string) => {
242+
if (!dashboard) return;
243+
const container = dashboard.containers?.find(c => c.id === containerId);
244+
if (!container?.tabs) return;
245+
const remaining = container.tabs.filter(t => t.id !== tabId);
246+
247+
setDashboard(
248+
produce(dashboard, draft => {
249+
const c = draft.containers?.find(c => c.id === containerId);
250+
if (!c?.tabs) return;
251+
252+
if (remaining.length <= 1) {
253+
// Keep the 1 remaining tab (don't clear tabs array)
254+
const keepTab = remaining[0];
255+
c.tabs = remaining;
256+
c.activeTabId = keepTab?.id;
257+
// Move tiles from deleted tab to the remaining tab
258+
for (const tile of draft.tiles) {
259+
if (tile.containerId === containerId && tile.tabId === tabId) {
260+
tile.tabId = keepTab?.id;
261+
}
262+
}
263+
} else {
264+
const targetTabId = remaining[0].id;
265+
// Move tiles from deleted tab to first remaining tab
266+
for (const tile of draft.tiles) {
267+
if (tile.containerId === containerId && tile.tabId === tabId) {
268+
tile.tabId = targetTabId;
269+
}
270+
}
271+
c.tabs = c.tabs.filter(t => t.id !== tabId);
272+
if (c.activeTabId === tabId) {
273+
c.activeTabId = targetTabId;
274+
}
275+
}
276+
}),
277+
);
278+
},
279+
[dashboard, setDashboard],
280+
);
281+
282+
const handleTabChange = useCallback(
283+
(containerId: string, tabId: string) => {
284+
if (!dashboard) return;
285+
setDashboard(
286+
produce(dashboard, draft => {
287+
const container = draft.containers?.find(c => c.id === containerId);
288+
if (container) container.activeTabId = tabId;
289+
}),
290+
);
291+
},
292+
[dashboard, setDashboard],
293+
);
294+
295+
return {
296+
handleAddContainer,
297+
handleToggleSection,
298+
handleRenameSection,
299+
handleDeleteSection,
300+
handleReorderSections,
301+
handleAddTab,
302+
handleRenameTab,
303+
handleDeleteTab,
304+
handleTabChange,
305+
};
306+
}
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)