@firecms/core 3.0.0-canary.248 → 3.0.0-canary.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/HomePage/DefaultHomePage.d.ts +2 -15
- package/dist/components/HomePage/HomePageDnD.d.ts +76 -0
- package/dist/components/HomePage/NavigationCard.d.ts +3 -1
- package/dist/components/HomePage/NavigationCardBinding.d.ts +3 -2
- package/dist/components/HomePage/NavigationGroup.d.ts +7 -1
- package/dist/components/HomePage/RenameGroupDialog.d.ts +9 -0
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/field_bindings/ReferenceAsStringFieldBinding.d.ts +9 -0
- package/dist/form/index.d.ts +1 -0
- package/dist/hooks/useBuildNavigationController.d.ts +51 -2
- package/dist/index.es.js +1726 -778
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1723 -775
- package/dist/index.umd.js.map +1 -1
- package/dist/types/analytics.d.ts +1 -1
- package/dist/types/collections.d.ts +3 -0
- package/dist/types/navigation.d.ts +20 -4
- package/dist/types/plugins.d.ts +12 -0
- package/dist/types/properties.d.ts +7 -0
- package/dist/types/property_config.d.ts +1 -1
- package/dist/util/icons.d.ts +1 -1
- package/package.json +5 -5
- package/src/components/EntityCollectionTable/PropertyTableCell.tsx +25 -3
- package/src/components/HomePage/DefaultHomePage.tsx +476 -157
- package/src/components/HomePage/FavouritesView.tsx +3 -3
- package/src/components/HomePage/HomePageDnD.tsx +613 -0
- package/src/components/HomePage/NavigationCard.tsx +47 -38
- package/src/components/HomePage/NavigationCardBinding.tsx +10 -6
- package/src/components/HomePage/NavigationGroup.tsx +63 -29
- package/src/components/HomePage/RenameGroupDialog.tsx +113 -0
- package/src/core/DefaultDrawer.tsx +8 -8
- package/src/core/DrawerNavigationItem.tsx +1 -1
- package/src/core/field_configs.tsx +15 -1
- package/src/form/field_bindings/ReferenceAsStringFieldBinding.tsx +135 -0
- package/src/form/field_bindings/RepeatFieldBinding.tsx +0 -1
- package/src/form/index.tsx +1 -0
- package/src/hooks/useBuildNavigationController.tsx +273 -84
- package/src/preview/PropertyPreview.tsx +14 -0
- package/src/types/analytics.ts +3 -0
- package/src/types/collections.ts +3 -0
- package/src/types/navigation.ts +27 -5
- package/src/types/plugins.tsx +15 -0
- package/src/types/properties.ts +8 -0
- package/src/types/property_config.tsx +1 -0
- package/src/util/icons.tsx +7 -3
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useNavigate } from "react-router-dom";
|
|
2
2
|
import { useNavigationController } from "../../hooks";
|
|
3
3
|
import { useUserConfigurationPersistence } from "../../hooks/useUserConfigurationPersistence";
|
|
4
|
-
import {
|
|
4
|
+
import { NavigationEntry } from "../../types";
|
|
5
5
|
import { Chip, Collapse, StarIcon } from "@firecms/ui";
|
|
6
6
|
|
|
7
|
-
function NavigationChip({ entry }: { entry:
|
|
7
|
+
function NavigationChip({ entry }: { entry: NavigationEntry }) {
|
|
8
8
|
|
|
9
9
|
const navigate = useNavigate();
|
|
10
10
|
const userConfigurationPersistence = useUserConfigurationPersistence();
|
|
@@ -48,7 +48,7 @@ export function FavouritesView({ hidden }: { hidden: boolean }) {
|
|
|
48
48
|
|
|
49
49
|
const favouriteCollections = (userConfigurationPersistence?.favouritePaths ?? [])
|
|
50
50
|
.map((path) => navigationController.topLevelNavigation?.navigationEntries.find((entry) => entry.path === path))
|
|
51
|
-
.filter(Boolean) as
|
|
51
|
+
.filter(Boolean) as NavigationEntry[];
|
|
52
52
|
|
|
53
53
|
return <Collapse in={favouriteCollections.length > 0}>
|
|
54
54
|
<div className="flex flex-row flex-wrap gap-2 pb-2 min-h-[32px]">
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Active,
|
|
4
|
+
closestCenter,
|
|
5
|
+
closestCorners,
|
|
6
|
+
CollisionDetection,
|
|
7
|
+
DropAnimation,
|
|
8
|
+
getFirstCollision,
|
|
9
|
+
KeyboardSensor,
|
|
10
|
+
MouseSensor,
|
|
11
|
+
pointerWithin,
|
|
12
|
+
rectIntersection,
|
|
13
|
+
TouchSensor,
|
|
14
|
+
UniqueIdentifier,
|
|
15
|
+
useDndMonitor,
|
|
16
|
+
useDroppable,
|
|
17
|
+
useSensor,
|
|
18
|
+
useSensors
|
|
19
|
+
} from "@dnd-kit/core";
|
|
20
|
+
import {
|
|
21
|
+
AnimateLayoutChanges,
|
|
22
|
+
arrayMove,
|
|
23
|
+
defaultAnimateLayoutChanges,
|
|
24
|
+
rectSortingStrategy,
|
|
25
|
+
SortableContext,
|
|
26
|
+
useSortable
|
|
27
|
+
} from "@dnd-kit/sortable";
|
|
28
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
29
|
+
|
|
30
|
+
import { NavigationCardBinding } from "./NavigationCardBinding";
|
|
31
|
+
import { NavigationEntry } from "../../types";
|
|
32
|
+
import { cls } from "@firecms/ui";
|
|
33
|
+
|
|
34
|
+
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
|
35
|
+
defaultAnimateLayoutChanges({
|
|
36
|
+
...args,
|
|
37
|
+
wasDragging: true
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const dropAnimationConfig: DropAnimation = {};
|
|
41
|
+
|
|
42
|
+
const cloneSerializableNavigationEntry = (entry: NavigationEntry): NavigationEntry => {
|
|
43
|
+
const clonedEntry: Partial<NavigationEntry> = {
|
|
44
|
+
id: entry.id,
|
|
45
|
+
path: entry.path,
|
|
46
|
+
url: entry.url,
|
|
47
|
+
name: entry.name,
|
|
48
|
+
type: entry.type,
|
|
49
|
+
collection: entry.collection ? { ...entry.collection } : undefined,
|
|
50
|
+
view: entry.view ? { ...entry.view } : undefined,
|
|
51
|
+
...(entry.group && { group: entry.group }),
|
|
52
|
+
...(entry.description && { description: entry.description })
|
|
53
|
+
};
|
|
54
|
+
return clonedEntry as NavigationEntry;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const cloneItemsForDnd = (items: { name: string; entries: NavigationEntry[] }[]) =>
|
|
58
|
+
items.map((g) => ({
|
|
59
|
+
name: g.name,
|
|
60
|
+
entries: g.entries.map(cloneSerializableNavigationEntry)
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
64
|
+
/* Sortable card & group */
|
|
65
|
+
|
|
66
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
67
|
+
export function SortableNavigationCard({
|
|
68
|
+
entry,
|
|
69
|
+
onClick
|
|
70
|
+
}: {
|
|
71
|
+
entry: NavigationEntry;
|
|
72
|
+
onClick?: () => void;
|
|
73
|
+
}) {
|
|
74
|
+
const {
|
|
75
|
+
setNodeRef,
|
|
76
|
+
listeners,
|
|
77
|
+
attributes,
|
|
78
|
+
transform,
|
|
79
|
+
transition,
|
|
80
|
+
isDragging
|
|
81
|
+
} =
|
|
82
|
+
useSortable({
|
|
83
|
+
id: entry.url,
|
|
84
|
+
animateLayoutChanges
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const style = useMemo(
|
|
88
|
+
() => ({
|
|
89
|
+
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
|
90
|
+
transition,
|
|
91
|
+
opacity: isDragging ? 0 : 1
|
|
92
|
+
}),
|
|
93
|
+
[transform, transition, isDragging]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
98
|
+
<NavigationCardBinding {...entry} onClick={onClick}/>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function NavigationGroupDroppable({
|
|
104
|
+
id,
|
|
105
|
+
itemIds,
|
|
106
|
+
children,
|
|
107
|
+
isPotentialCardDropTarget = false
|
|
108
|
+
}: {
|
|
109
|
+
id: UniqueIdentifier;
|
|
110
|
+
itemIds: UniqueIdentifier[];
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
isPotentialCardDropTarget?: boolean;
|
|
113
|
+
}) {
|
|
114
|
+
const { setNodeRef } = useDroppable({ id });
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
ref={setNodeRef}
|
|
119
|
+
className={cls(
|
|
120
|
+
isPotentialCardDropTarget
|
|
121
|
+
? "p-2 bg-surface-accent-200 dark:bg-surface-accent-800 rounded-lg"
|
|
122
|
+
: undefined,
|
|
123
|
+
"transition-all duration-200 ease-in-out"
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<SortableContext items={itemIds} strategy={rectSortingStrategy}>
|
|
127
|
+
{children}
|
|
128
|
+
</SortableContext>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function SortableNavigationGroup({
|
|
134
|
+
groupName,
|
|
135
|
+
children,
|
|
136
|
+
disabled
|
|
137
|
+
}: {
|
|
138
|
+
groupName: string;
|
|
139
|
+
children: React.ReactNode;
|
|
140
|
+
disabled?: boolean;
|
|
141
|
+
}) {
|
|
142
|
+
const {
|
|
143
|
+
attributes,
|
|
144
|
+
listeners,
|
|
145
|
+
setNodeRef,
|
|
146
|
+
transform,
|
|
147
|
+
transition,
|
|
148
|
+
isDragging
|
|
149
|
+
} =
|
|
150
|
+
useSortable({
|
|
151
|
+
id: groupName,
|
|
152
|
+
animateLayoutChanges,
|
|
153
|
+
disabled
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const style = useMemo(
|
|
157
|
+
() => ({
|
|
158
|
+
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
|
159
|
+
transition,
|
|
160
|
+
opacity: isDragging ? 0 : 1
|
|
161
|
+
}),
|
|
162
|
+
[transform, transition, isDragging]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
167
|
+
{children}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
173
|
+
/* Main DnD hook */
|
|
174
|
+
|
|
175
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
176
|
+
export function useHomePageDnd({
|
|
177
|
+
items: dndItems,
|
|
178
|
+
setItems: setDndItems,
|
|
179
|
+
disabled,
|
|
180
|
+
onCardMovedBetweenGroups,
|
|
181
|
+
onGroupMoved,
|
|
182
|
+
onNewGroupDrop,
|
|
183
|
+
onPersist
|
|
184
|
+
}: {
|
|
185
|
+
items: { name: string; entries: NavigationEntry[] }[];
|
|
186
|
+
setItems: (
|
|
187
|
+
newItemsOrUpdater:
|
|
188
|
+
| { name: string; entries: NavigationEntry[] }[]
|
|
189
|
+
| ((
|
|
190
|
+
currentItems: { name: string; entries: NavigationEntry[] }[]
|
|
191
|
+
) => { name: string; entries: NavigationEntry[] }[])
|
|
192
|
+
) => void;
|
|
193
|
+
disabled: boolean;
|
|
194
|
+
onCardMovedBetweenGroups?: (card: NavigationEntry) => void;
|
|
195
|
+
onGroupMoved?: (groupName: string, oldIndex: number, newIndex: number) => void;
|
|
196
|
+
onNewGroupDrop?: () => void;
|
|
197
|
+
onPersist?: (latest: { name: string; entries: NavigationEntry[] }[]) => void;
|
|
198
|
+
}) {
|
|
199
|
+
/* ---------------- local state ---------------- */
|
|
200
|
+
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
|
201
|
+
const [activeIsGroup, setActiveIsGroup] = useState(false);
|
|
202
|
+
const [currentDraggingGroupId, setCurrentDraggingGroupId] =
|
|
203
|
+
useState<UniqueIdentifier | null>(null);
|
|
204
|
+
const [dndKitActiveNode, setDndKitActiveNode] = useState<Active | null>(null);
|
|
205
|
+
const [isDraggingCardOnly, setIsDraggingCardOnly] = useState(false);
|
|
206
|
+
const [dialogOpenForGroup, setDialogOpenForGroup] = useState<string | null>(null);
|
|
207
|
+
const [isHoveringNewGroupDropZone, setIsHoveringNewGroupDropZone] =
|
|
208
|
+
useState(false);
|
|
209
|
+
|
|
210
|
+
/* store interim state for cross-group moves */
|
|
211
|
+
const interimItemsRef = useRef<
|
|
212
|
+
{ name: string; entries: NavigationEntry[] }[] | null
|
|
213
|
+
>(null);
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
interimItemsRef.current = dndItems;
|
|
216
|
+
}, [dndItems]);
|
|
217
|
+
|
|
218
|
+
/* ---------------- sensors ---------------- */
|
|
219
|
+
const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10 } });
|
|
220
|
+
const touchSensor = useSensor(TouchSensor, {
|
|
221
|
+
activationConstraint: {
|
|
222
|
+
delay: 150,
|
|
223
|
+
tolerance: 5
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
const keyboardSensor = useSensor(KeyboardSensor);
|
|
227
|
+
const sensors = useSensors(
|
|
228
|
+
...(disabled ? [] : [mouseSensor, touchSensor, keyboardSensor])
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/* ---------------- helpers ---------------- */
|
|
232
|
+
const dndContainers = useMemo(
|
|
233
|
+
() => dndItems.map((g) => g.name),
|
|
234
|
+
[dndItems]
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const findDndContainer = useCallback(
|
|
238
|
+
(id: UniqueIdentifier): string | undefined => {
|
|
239
|
+
if (!id) return undefined;
|
|
240
|
+
const group = dndItems.find((g) => g.name === id);
|
|
241
|
+
if (group) return group.name;
|
|
242
|
+
for (const g of dndItems) {
|
|
243
|
+
if (g.entries.some((e) => e.url === id)) return g.name;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
},
|
|
247
|
+
[dndItems]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
/* ---------------- collision detection ---------------- */
|
|
251
|
+
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
|
252
|
+
const recentlyMovedToNewContainer = useRef(false);
|
|
253
|
+
|
|
254
|
+
const collisionDetection: CollisionDetection = useCallback(
|
|
255
|
+
(args) => {
|
|
256
|
+
if (disabled || !activeId) return [];
|
|
257
|
+
if (activeIsGroup) {
|
|
258
|
+
const groups = args.droppableContainers.filter((c) =>
|
|
259
|
+
dndItems.some((g) => g.name === c.id)
|
|
260
|
+
);
|
|
261
|
+
if (!groups.length) return [];
|
|
262
|
+
return closestCenter({
|
|
263
|
+
...args,
|
|
264
|
+
droppableContainers: groups
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const pointer = pointerWithin(args);
|
|
269
|
+
if (pointer.length) {
|
|
270
|
+
const zone = pointer.find((c) => c.id === "new-group-drop-zone");
|
|
271
|
+
if (zone) return [zone];
|
|
272
|
+
|
|
273
|
+
const container = pointer.find((c) =>
|
|
274
|
+
dndItems.some((g) => g.name === c.id)
|
|
275
|
+
);
|
|
276
|
+
if (container) {
|
|
277
|
+
const itemsIn = dndItems.find((g) => g.name === container.id)
|
|
278
|
+
?.entries;
|
|
279
|
+
if (itemsIn?.length) {
|
|
280
|
+
const closest = closestCorners({
|
|
281
|
+
...args,
|
|
282
|
+
droppableContainers: args.droppableContainers.filter(
|
|
283
|
+
(c) => itemsIn.some((e) => e.url === c.id)
|
|
284
|
+
)
|
|
285
|
+
});
|
|
286
|
+
if (closest.length) return closest;
|
|
287
|
+
}
|
|
288
|
+
return [container];
|
|
289
|
+
}
|
|
290
|
+
const first = getFirstCollision(pointer, "id");
|
|
291
|
+
if (first) return [{ id: first }];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rects = rectIntersection(args);
|
|
295
|
+
const zoneRect = rects.find((c) => c.id === "new-group-drop-zone");
|
|
296
|
+
if (zoneRect) return [zoneRect];
|
|
297
|
+
|
|
298
|
+
let overId = getFirstCollision(rects, "id");
|
|
299
|
+
if (overId != null) {
|
|
300
|
+
const overIsContainer = dndItems.some((g) => g.name === overId);
|
|
301
|
+
if (overIsContainer) {
|
|
302
|
+
const itemsIn = dndItems.find((g) => g.name === overId)
|
|
303
|
+
?.entries;
|
|
304
|
+
if (itemsIn?.length) {
|
|
305
|
+
const closestItem = closestCorners({
|
|
306
|
+
...args,
|
|
307
|
+
droppableContainers: args.droppableContainers.filter(
|
|
308
|
+
(c) => itemsIn.some((e) => e.url === c.id)
|
|
309
|
+
)
|
|
310
|
+
})[0]?.id;
|
|
311
|
+
if (closestItem) overId = closestItem;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
lastOverId.current = overId;
|
|
315
|
+
return [{ id: overId }];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
recentlyMovedToNewContainer.current &&
|
|
320
|
+
lastOverId.current &&
|
|
321
|
+
!activeIsGroup
|
|
322
|
+
)
|
|
323
|
+
return [{ id: lastOverId.current }];
|
|
324
|
+
|
|
325
|
+
return [];
|
|
326
|
+
},
|
|
327
|
+
[activeId, dndItems, disabled, activeIsGroup]
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
/* ---------------- drag handlers ---------------- */
|
|
331
|
+
const handleDragStart = ({ active }: { active: Active }) => {
|
|
332
|
+
setDndKitActiveNode(active);
|
|
333
|
+
if (disabled) return;
|
|
334
|
+
|
|
335
|
+
const isGroup = dndItems.some((g) => g.name === active.id);
|
|
336
|
+
if (!active.data.current) active.data.current = {};
|
|
337
|
+
active.data.current.type = isGroup ? "group" : "item";
|
|
338
|
+
|
|
339
|
+
setActiveId(active.id);
|
|
340
|
+
setActiveIsGroup(isGroup);
|
|
341
|
+
setIsDraggingCardOnly(!isGroup);
|
|
342
|
+
if (isGroup) setCurrentDraggingGroupId(active.id);
|
|
343
|
+
recentlyMovedToNewContainer.current = false;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const handleDragOver = ({
|
|
347
|
+
active,
|
|
348
|
+
over
|
|
349
|
+
}: { active: Active; over: any }) => {
|
|
350
|
+
if (disabled || !over) return;
|
|
351
|
+
|
|
352
|
+
const activeIdNow = active.id;
|
|
353
|
+
const overIdNow = over.id;
|
|
354
|
+
if (activeIdNow === overIdNow) return;
|
|
355
|
+
if (activeIsGroup) return;
|
|
356
|
+
|
|
357
|
+
const activeCont = findDndContainer(activeIdNow);
|
|
358
|
+
const overCont = findDndContainer(overIdNow);
|
|
359
|
+
if (!activeCont) return;
|
|
360
|
+
|
|
361
|
+
if (overCont && activeCont !== overCont) {
|
|
362
|
+
recentlyMovedToNewContainer.current = true;
|
|
363
|
+
const newState = cloneItemsForDnd(dndItems);
|
|
364
|
+
const srcIdx = newState.findIndex((g) => g.name === activeCont);
|
|
365
|
+
const tgtIdx = newState.findIndex((g) => g.name === overCont);
|
|
366
|
+
if (srcIdx === -1 || tgtIdx === -1) return;
|
|
367
|
+
const src = newState[srcIdx];
|
|
368
|
+
const tgt = newState[tgtIdx];
|
|
369
|
+
const idxInSrc = src.entries.findIndex((e) => e.url === activeIdNow);
|
|
370
|
+
if (idxInSrc === -1) return;
|
|
371
|
+
const [moved] = src.entries.splice(idxInSrc, 1);
|
|
372
|
+
tgt.entries.push(moved);
|
|
373
|
+
interimItemsRef.current = newState;
|
|
374
|
+
setDndItems(newState);
|
|
375
|
+
} else if (activeCont === overCont) {
|
|
376
|
+
recentlyMovedToNewContainer.current = false;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const handleDragEnd = ({
|
|
381
|
+
active,
|
|
382
|
+
over
|
|
383
|
+
}: { active: Active; over: any }) => {
|
|
384
|
+
if (disabled || !over) {
|
|
385
|
+
resetDragState();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const activeIdNow = active.id;
|
|
390
|
+
const overIdNow = over.id;
|
|
391
|
+
|
|
392
|
+
/* ─── group reorder ─── */
|
|
393
|
+
if (activeIsGroup) {
|
|
394
|
+
if (
|
|
395
|
+
activeIdNow !== overIdNow &&
|
|
396
|
+
dndItems.some((g) => g.name === overIdNow)
|
|
397
|
+
) {
|
|
398
|
+
const from = dndItems.findIndex((g) => g.name === activeIdNow);
|
|
399
|
+
const to = dndItems.findIndex((g) => g.name === overIdNow);
|
|
400
|
+
if (from !== -1 && to !== -1) {
|
|
401
|
+
const newState = arrayMove(dndItems, from, to);
|
|
402
|
+
setDndItems(newState);
|
|
403
|
+
onPersist?.(newState);
|
|
404
|
+
onGroupMoved?.(activeIdNow as string, from, to);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/* ─── card move ─── */
|
|
409
|
+
else {
|
|
410
|
+
const activeCont = findDndContainer(activeIdNow);
|
|
411
|
+
|
|
412
|
+
/* drop on new-group zone */
|
|
413
|
+
if (overIdNow === "new-group-drop-zone") {
|
|
414
|
+
if (activeCont) {
|
|
415
|
+
const newState = cloneItemsForDnd(dndItems);
|
|
416
|
+
const srcIdx = newState.findIndex((g) => g.name === activeCont);
|
|
417
|
+
if (srcIdx !== -1) {
|
|
418
|
+
const src = newState[srcIdx];
|
|
419
|
+
const idxInSrc = src.entries.findIndex(
|
|
420
|
+
(e) => e.url === activeIdNow
|
|
421
|
+
);
|
|
422
|
+
if (idxInSrc !== -1) {
|
|
423
|
+
const [dragged] = src.entries.splice(idxInSrc, 1);
|
|
424
|
+
if (src.entries.length === 0) newState.splice(srcIdx, 1);
|
|
425
|
+
|
|
426
|
+
let tentative = "New Group";
|
|
427
|
+
let counter = 1;
|
|
428
|
+
while (newState.some((g) => g.name === tentative))
|
|
429
|
+
tentative = `New Group ${counter++}`;
|
|
430
|
+
|
|
431
|
+
newState.push({
|
|
432
|
+
name: tentative,
|
|
433
|
+
entries: [dragged]
|
|
434
|
+
});
|
|
435
|
+
setDndItems(newState);
|
|
436
|
+
onPersist?.(newState);
|
|
437
|
+
setDialogOpenForGroup(tentative);
|
|
438
|
+
onNewGroupDrop?.();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/* reorder inside same container */
|
|
444
|
+
else {
|
|
445
|
+
const overCont = findDndContainer(overIdNow);
|
|
446
|
+
if (activeCont === overCont) {
|
|
447
|
+
const grpIdx = dndItems.findIndex((g) => g.name === activeCont);
|
|
448
|
+
if (grpIdx !== -1) {
|
|
449
|
+
const group = dndItems[grpIdx];
|
|
450
|
+
const oldIdx = group.entries.findIndex(
|
|
451
|
+
(e) => e.url === activeIdNow
|
|
452
|
+
);
|
|
453
|
+
let newIdx = group.entries.findIndex(
|
|
454
|
+
(e) => e.url === overIdNow
|
|
455
|
+
);
|
|
456
|
+
if (newIdx === -1 && overIdNow === activeCont)
|
|
457
|
+
newIdx = group.entries.length - 1;
|
|
458
|
+
if (
|
|
459
|
+
oldIdx !== -1 &&
|
|
460
|
+
newIdx !== -1 &&
|
|
461
|
+
oldIdx !== newIdx
|
|
462
|
+
) {
|
|
463
|
+
const reordered = arrayMove(group.entries, oldIdx, newIdx);
|
|
464
|
+
const newState = [...dndItems];
|
|
465
|
+
newState[grpIdx] = {
|
|
466
|
+
...group,
|
|
467
|
+
entries: reordered
|
|
468
|
+
};
|
|
469
|
+
setDndItems(newState);
|
|
470
|
+
onPersist?.(newState);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} else if (
|
|
474
|
+
recentlyMovedToNewContainer.current &&
|
|
475
|
+
interimItemsRef.current
|
|
476
|
+
) {
|
|
477
|
+
onPersist?.(interimItemsRef.current);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
onCardMovedBetweenGroups?.(
|
|
481
|
+
dndItems
|
|
482
|
+
.flatMap((g) => g.entries)
|
|
483
|
+
.find((e) => e.url === activeIdNow)!
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
resetDragState();
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const resetDragState = () => {
|
|
492
|
+
setDndKitActiveNode(null);
|
|
493
|
+
setActiveId(null);
|
|
494
|
+
setActiveIsGroup(false);
|
|
495
|
+
setCurrentDraggingGroupId(null);
|
|
496
|
+
setIsDraggingCardOnly(false);
|
|
497
|
+
recentlyMovedToNewContainer.current = false;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const handleDragCancel = () => resetDragState();
|
|
501
|
+
|
|
502
|
+
/* ---------------- group rename ---------------- */
|
|
503
|
+
const handleRenameGroup = (oldName: string, newName: string) => {
|
|
504
|
+
setDndItems((current) => {
|
|
505
|
+
const idx = current.findIndex((g) => g.name === oldName);
|
|
506
|
+
if (idx === -1) return current;
|
|
507
|
+
if (current.some((g) => g.name === newName && g.name !== oldName))
|
|
508
|
+
return current;
|
|
509
|
+
|
|
510
|
+
const updated = [...current];
|
|
511
|
+
updated[idx] = {
|
|
512
|
+
...updated[idx],
|
|
513
|
+
name: newName
|
|
514
|
+
};
|
|
515
|
+
onPersist?.(updated); // <- ensure rename is saved
|
|
516
|
+
return updated;
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/* ---------------- public API ---------------- */
|
|
521
|
+
const activeItemForOverlay = useMemo(() => {
|
|
522
|
+
if (disabled || !activeId || activeIsGroup) return null;
|
|
523
|
+
return (
|
|
524
|
+
dndItems.flatMap((g) => g.entries).find((e) => e.url === activeId) ||
|
|
525
|
+
null
|
|
526
|
+
);
|
|
527
|
+
}, [activeId, dndItems, disabled, activeIsGroup]);
|
|
528
|
+
|
|
529
|
+
const activeGroupData = useMemo(() => {
|
|
530
|
+
if (disabled || !activeId || !activeIsGroup) return null;
|
|
531
|
+
return dndItems.find((g) => g.name === activeId) || null;
|
|
532
|
+
}, [activeId, dndItems, disabled, activeIsGroup]);
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
sensors,
|
|
536
|
+
collisionDetection,
|
|
537
|
+
onDragStart: handleDragStart,
|
|
538
|
+
onDragOver: handleDragOver,
|
|
539
|
+
onDragEnd: handleDragEnd,
|
|
540
|
+
onDragCancel: handleDragCancel,
|
|
541
|
+
dropAnimation: dropAnimationConfig,
|
|
542
|
+
activeItemForOverlay,
|
|
543
|
+
activeGroupData,
|
|
544
|
+
draggingGroupId: currentDraggingGroupId,
|
|
545
|
+
containers: dndContainers,
|
|
546
|
+
dndKitActiveNode,
|
|
547
|
+
isDraggingCardOnly,
|
|
548
|
+
dialogOpenForGroup,
|
|
549
|
+
setDialogOpenForGroup,
|
|
550
|
+
handleRenameGroup,
|
|
551
|
+
isHoveringNewGroupDropZone,
|
|
552
|
+
setIsHoveringNewGroupDropZone
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
557
|
+
/* New-group drop-zone component */
|
|
558
|
+
|
|
559
|
+
/* ─────────────────────────────────────────────────────────── */
|
|
560
|
+
export function NewGroupDropZone({
|
|
561
|
+
disabled,
|
|
562
|
+
setIsHovering
|
|
563
|
+
}: {
|
|
564
|
+
disabled: boolean;
|
|
565
|
+
setIsHovering: (v: boolean) => void;
|
|
566
|
+
}) {
|
|
567
|
+
const {
|
|
568
|
+
setNodeRef,
|
|
569
|
+
isOver
|
|
570
|
+
} = useDroppable({
|
|
571
|
+
id: "new-group-drop-zone",
|
|
572
|
+
disabled
|
|
573
|
+
});
|
|
574
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
575
|
+
|
|
576
|
+
useDndMonitor({
|
|
577
|
+
onDragStart({ active }) {
|
|
578
|
+
if (disabled) return;
|
|
579
|
+
const tp = active.data.current?.type;
|
|
580
|
+
setIsVisible(tp === "item");
|
|
581
|
+
},
|
|
582
|
+
onDragEnd() {
|
|
583
|
+
setIsVisible(false);
|
|
584
|
+
},
|
|
585
|
+
onDragCancel() {
|
|
586
|
+
setIsVisible(false);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
setIsHovering(isOver && isVisible);
|
|
592
|
+
}, [isOver, isVisible, setIsHovering]);
|
|
593
|
+
|
|
594
|
+
if (!isVisible || disabled) return null;
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<div
|
|
598
|
+
ref={setNodeRef}
|
|
599
|
+
className={cls(
|
|
600
|
+
"fixed right-8 top-1/2 -translate-y-1/2 w-[200px] h-[120px] border border-dashed rounded-lg flex items-center justify-center transition-all",
|
|
601
|
+
isOver
|
|
602
|
+
? "bg-surface-accent-100 dark:bg-surface-accent-800 border-surface-300 dark:border-surface-600"
|
|
603
|
+
: "bg-surface-50 dark:bg-surface-900 border-surface-200 dark:border-surface-700"
|
|
604
|
+
)}
|
|
605
|
+
>
|
|
606
|
+
<div className="text-center p-4">
|
|
607
|
+
<span className="block font-medium text-sm">
|
|
608
|
+
Drop here to create a new group
|
|
609
|
+
</span>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|