Skip to content

Commit b316366

Browse files
committed
feat: Extract container and tile selection hooks
useDashboardContainers (250 lines): container CRUD, tab management. Tab handlers operate on container.tabs array directly (no child containers). handleMoveTileToSection accepts optional tabId. useTileSelection (74 lines): Shift+click selection + Cmd+G grouping.
1 parent 110cc6c commit b316366

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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+
if (type === 'tab') {
37+
const firstTabId = makeId();
38+
draft.containers.push({
39+
id: containerId,
40+
type,
41+
title: titles[type],
42+
collapsed: false,
43+
tabs: [{ id: firstTabId, title: 'Tab 1' }],
44+
activeTabId: firstTabId,
45+
});
46+
} else {
47+
draft.containers.push({
48+
id: containerId,
49+
type,
50+
title: titles[type],
51+
collapsed: false,
52+
});
53+
}
54+
}),
55+
);
56+
},
57+
[dashboard, setDashboard],
58+
);
59+
60+
// Intentionally persists collapsed state to the server via setDashboard
61+
// (same pattern as tile drag/resize). This matches Grafana and Kibana
62+
// behavior where collapsed state is saved with the dashboard for all viewers.
63+
const handleToggleSection = useCallback(
64+
(containerId: string) => {
65+
if (!dashboard) return;
66+
setDashboard(
67+
produce(dashboard, draft => {
68+
const section = draft.containers?.find(s => s.id === containerId);
69+
if (section) section.collapsed = !section.collapsed;
70+
}),
71+
);
72+
},
73+
[dashboard, setDashboard],
74+
);
75+
76+
const handleRenameSection = useCallback(
77+
(containerId: string, newTitle: string) => {
78+
if (!dashboard || !newTitle.trim()) return;
79+
setDashboard(
80+
produce(dashboard, draft => {
81+
const section = draft.containers?.find(s => s.id === containerId);
82+
if (section) section.title = newTitle.trim();
83+
}),
84+
);
85+
},
86+
[dashboard, setDashboard],
87+
);
88+
89+
const handleDeleteSection = useCallback(
90+
async (containerId: string) => {
91+
if (!dashboard) return;
92+
const container = dashboard.containers?.find(c => c.id === containerId);
93+
const tileCount = dashboard.tiles.filter(
94+
t => t.containerId === containerId,
95+
).length;
96+
const label = container?.title ?? 'this section';
97+
98+
const confirmed = await confirm(
99+
<>
100+
Delete{' '}
101+
<Text component="span" fw={700}>
102+
{label}
103+
</Text>
104+
?
105+
{tileCount > 0 &&
106+
` ${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`}
107+
</>,
108+
'Delete',
109+
{ variant: 'danger' },
110+
);
111+
if (!confirmed) return;
112+
113+
setDashboard(
114+
produce(dashboard, draft => {
115+
const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
116+
let maxUngroupedY = 0;
117+
for (const tile of draft.tiles) {
118+
if (!tile.containerId || !allSectionIds.has(tile.containerId)) {
119+
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
120+
}
121+
}
122+
123+
for (const tile of draft.tiles) {
124+
if (tile.containerId === containerId) {
125+
tile.y += maxUngroupedY;
126+
delete tile.containerId;
127+
delete tile.tabId;
128+
}
129+
}
130+
131+
draft.containers = draft.containers?.filter(
132+
s => s.id !== containerId,
133+
);
134+
}),
135+
);
136+
},
137+
[dashboard, setDashboard, confirm],
138+
);
139+
140+
const handleReorderSections = useCallback(
141+
(fromIndex: number, toIndex: number) => {
142+
if (!dashboard?.containers) return;
143+
setDashboard(
144+
produce(dashboard, draft => {
145+
if (draft.containers) {
146+
draft.containers = arrayMove(draft.containers, fromIndex, toIndex);
147+
}
148+
}),
149+
);
150+
},
151+
[dashboard, setDashboard],
152+
);
153+
154+
// --- Tab management ---
155+
156+
const handleAddTab = useCallback(
157+
(containerId: string) => {
158+
if (!dashboard) return;
159+
const container = dashboard.containers?.find(c => c.id === containerId);
160+
if (!container) return;
161+
const existingTabs = container.tabs ?? [];
162+
const newTabId = makeId();
163+
setDashboard(
164+
produce(dashboard, draft => {
165+
const c = draft.containers?.find(c => c.id === containerId);
166+
if (!c) return;
167+
if (!c.tabs) c.tabs = [];
168+
c.tabs.push({
169+
id: newTabId,
170+
title: `Tab ${existingTabs.length + 1}`,
171+
});
172+
// Auto-switch to the new tab
173+
c.activeTabId = newTabId;
174+
}),
175+
);
176+
},
177+
[dashboard, setDashboard],
178+
);
179+
180+
const handleRenameTab = useCallback(
181+
(containerId: string, tabId: string, newTitle: string) => {
182+
if (!dashboard || !newTitle.trim()) return;
183+
setDashboard(
184+
produce(dashboard, draft => {
185+
const container = draft.containers?.find(c => c.id === containerId);
186+
const tab = container?.tabs?.find(t => t.id === tabId);
187+
if (tab) tab.title = newTitle.trim();
188+
}),
189+
);
190+
},
191+
[dashboard, setDashboard],
192+
);
193+
194+
const handleDeleteTab = useCallback(
195+
(containerId: string, tabId: string) => {
196+
if (!dashboard) return;
197+
const container = dashboard.containers?.find(c => c.id === containerId);
198+
if (!container?.tabs) return;
199+
const remaining = container.tabs.filter(t => t.id !== tabId);
200+
// Don't delete the last tab
201+
if (remaining.length === 0) return;
202+
203+
setDashboard(
204+
produce(dashboard, draft => {
205+
const c = draft.containers?.find(c => c.id === containerId);
206+
if (!c?.tabs) return;
207+
const targetTabId = remaining[0].id;
208+
// Move tiles from deleted tab to first remaining tab
209+
for (const tile of draft.tiles) {
210+
if (tile.containerId === containerId && tile.tabId === tabId) {
211+
tile.tabId = targetTabId;
212+
}
213+
}
214+
// Remove the tab
215+
c.tabs = c.tabs.filter(t => t.id !== tabId);
216+
// Update activeTabId if it pointed to deleted tab
217+
if (c.activeTabId === tabId) {
218+
c.activeTabId = targetTabId;
219+
}
220+
}),
221+
);
222+
},
223+
[dashboard, setDashboard],
224+
);
225+
226+
const handleTabChange = useCallback(
227+
(containerId: string, tabId: string) => {
228+
if (!dashboard) return;
229+
setDashboard(
230+
produce(dashboard, draft => {
231+
const container = draft.containers?.find(c => c.id === containerId);
232+
if (container) container.activeTabId = tabId;
233+
}),
234+
);
235+
},
236+
[dashboard, setDashboard],
237+
);
238+
239+
return {
240+
handleAddContainer,
241+
handleToggleSection,
242+
handleRenameSection,
243+
handleDeleteSection,
244+
handleReorderSections,
245+
handleAddTab,
246+
handleRenameTab,
247+
handleDeleteTab,
248+
handleTabChange,
249+
};
250+
}
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)