@checkstack/catalog-frontend 0.4.2 → 0.5.0
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/CHANGELOG.md +27 -0
- package/package.json +8 -6
- package/src/components/CatalogConfigPage.tsx +189 -235
- package/src/components/DraggableSystem.tsx +178 -0
- package/src/components/DroppableGroup.tsx +133 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @checkstack/catalog-frontend
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0ebbe56: Replace dropdown-based system-to-group assignment with drag-and-drop.
|
|
8
|
+
|
|
9
|
+
- Systems panel now shows a grip handle on each row for drag-and-drop onto groups
|
|
10
|
+
- Group panel cards highlight as valid drop zones when a system is dragged over them
|
|
11
|
+
- Dragging a system onto a group it already belongs to is blocked with a visual indicator
|
|
12
|
+
- Added a `+` popover button on each system row as a mobile-friendly alternative (no drag required on small screens)
|
|
13
|
+
- Touch sensor activated with 250ms delay to avoid conflicts with scrolling
|
|
14
|
+
- Removed the "Add System to Group" card with dropdowns
|
|
15
|
+
- Systems not assigned to any group display an `unassigned` badge
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [0ebbe56]
|
|
20
|
+
- Updated dependencies [a340781]
|
|
21
|
+
- Updated dependencies [8d2660d]
|
|
22
|
+
- @checkstack/auth-common@0.5.6
|
|
23
|
+
- @checkstack/common@0.6.3
|
|
24
|
+
- @checkstack/ui@1.1.1
|
|
25
|
+
- @checkstack/auth-frontend@0.5.11
|
|
26
|
+
- @checkstack/catalog-common@1.2.8
|
|
27
|
+
- @checkstack/frontend-api@0.3.6
|
|
28
|
+
- @checkstack/notification-common@0.2.6
|
|
29
|
+
|
|
3
30
|
## 0.4.2
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"scripts": {
|
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@checkstack/auth-common": "0.5.5",
|
|
13
|
+
"@checkstack/auth-frontend": "0.5.10",
|
|
13
14
|
"@checkstack/catalog-common": "1.2.7",
|
|
14
|
-
"@checkstack/frontend-api": "0.3.5",
|
|
15
|
-
"@checkstack/auth-frontend": "0.5.9",
|
|
16
15
|
"@checkstack/common": "0.6.2",
|
|
16
|
+
"@checkstack/frontend-api": "0.3.5",
|
|
17
17
|
"@checkstack/notification-common": "0.2.5",
|
|
18
|
-
"@checkstack/ui": "1.
|
|
18
|
+
"@checkstack/ui": "1.1.0",
|
|
19
|
+
"@dnd-kit/core": "^6.3.1",
|
|
20
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
21
|
+
"lucide-react": "^0.344.0",
|
|
19
22
|
"react": "^18.2.0",
|
|
20
|
-
"react-router-dom": "^6.22.0"
|
|
21
|
-
"lucide-react": "^0.344.0"
|
|
23
|
+
"react-router-dom": "^6.22.0"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"typescript": "^5.0.0",
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
2
|
import { useSearchParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
DndContext,
|
|
5
|
+
DragOverlay,
|
|
6
|
+
PointerSensor,
|
|
7
|
+
TouchSensor,
|
|
8
|
+
useSensor,
|
|
9
|
+
useSensors,
|
|
10
|
+
type DragStartEvent,
|
|
11
|
+
type DragEndEvent,
|
|
12
|
+
type DragOverEvent,
|
|
13
|
+
} from "@dnd-kit/core";
|
|
3
14
|
import {
|
|
4
15
|
useApi,
|
|
5
16
|
accessApiRef,
|
|
6
|
-
ExtensionSlot,
|
|
7
17
|
usePluginClient,
|
|
8
18
|
} from "@checkstack/frontend-api";
|
|
9
19
|
import { System, CatalogApi } from "../api";
|
|
10
|
-
import {
|
|
11
|
-
CatalogSystemActionsSlot,
|
|
12
|
-
catalogAccess,
|
|
13
|
-
} from "@checkstack/catalog-common";
|
|
20
|
+
import { catalogAccess } from "@checkstack/catalog-common";
|
|
14
21
|
import {
|
|
15
22
|
PageLayout,
|
|
16
23
|
Card,
|
|
@@ -19,15 +26,15 @@ import {
|
|
|
19
26
|
CardTitle,
|
|
20
27
|
CardContent,
|
|
21
28
|
Button,
|
|
22
|
-
Label,
|
|
23
29
|
EmptyState,
|
|
24
|
-
EditableText,
|
|
25
30
|
ConfirmationModal,
|
|
26
31
|
useToast,
|
|
27
32
|
} from "@checkstack/ui";
|
|
28
|
-
import { Plus,
|
|
33
|
+
import { Plus, FolderPlus, LayoutGrid, Server } from "lucide-react";
|
|
29
34
|
import { SystemEditor } from "./SystemEditor";
|
|
30
35
|
import { GroupEditor } from "./GroupEditor";
|
|
36
|
+
import { DraggableSystem, SystemDragOverlay } from "./DraggableSystem";
|
|
37
|
+
import { DroppableGroup } from "./DroppableGroup";
|
|
31
38
|
|
|
32
39
|
export const CatalogConfigPage = () => {
|
|
33
40
|
const catalogClient = usePluginClient(CatalogApi);
|
|
@@ -38,9 +45,6 @@ export const CatalogConfigPage = () => {
|
|
|
38
45
|
catalogAccess.system.manage,
|
|
39
46
|
);
|
|
40
47
|
|
|
41
|
-
const [selectedGroupId, setSelectedGroupId] = useState("");
|
|
42
|
-
const [selectedSystemToAdd, setSelectedSystemToAdd] = useState("");
|
|
43
|
-
|
|
44
48
|
// Dialog state
|
|
45
49
|
const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
|
|
46
50
|
const [editingSystem, setEditingSystem] = useState<System | undefined>();
|
|
@@ -59,6 +63,12 @@ export const CatalogConfigPage = () => {
|
|
|
59
63
|
onConfirm: () => {},
|
|
60
64
|
});
|
|
61
65
|
|
|
66
|
+
// Drag-and-drop state
|
|
67
|
+
const [activeSystemId, setActiveSystemId] = useState<string | undefined>();
|
|
68
|
+
const [overGroupId, setOverGroupId] = useState<string | undefined>();
|
|
69
|
+
// Tracks which system was most recently added to which group (for glow animation)
|
|
70
|
+
const [recentlyAdded, setRecentlyAdded] = useState<{ systemId: string; groupId: string } | undefined>();
|
|
71
|
+
|
|
62
72
|
// Fetch systems with useQuery
|
|
63
73
|
const {
|
|
64
74
|
data: systemsData,
|
|
@@ -77,13 +87,6 @@ export const CatalogConfigPage = () => {
|
|
|
77
87
|
const groups = groupsData ?? [];
|
|
78
88
|
const loading = systemsLoading || groupsLoading;
|
|
79
89
|
|
|
80
|
-
// Set initial group selection
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (groups.length > 0 && !selectedGroupId) {
|
|
83
|
-
setSelectedGroupId(groups[0].id);
|
|
84
|
-
}
|
|
85
|
-
}, [groups, selectedGroupId]);
|
|
86
|
-
|
|
87
90
|
// Handle ?action=create URL parameter (from command palette)
|
|
88
91
|
useEffect(() => {
|
|
89
92
|
if (searchParams.get("action") === "create" && canManage) {
|
|
@@ -94,6 +97,18 @@ export const CatalogConfigPage = () => {
|
|
|
94
97
|
}
|
|
95
98
|
}, [searchParams, canManage, setSearchParams]);
|
|
96
99
|
|
|
100
|
+
// DnD sensors — pointer for desktop, touch for mobile
|
|
101
|
+
const sensors = useSensors(
|
|
102
|
+
useSensor(PointerSensor, {
|
|
103
|
+
// 8px movement tolerance prevents accidental drags on clicks
|
|
104
|
+
activationConstraint: { distance: 8 },
|
|
105
|
+
}),
|
|
106
|
+
useSensor(TouchSensor, {
|
|
107
|
+
// 250ms delay for touch to avoid conflicts with scrolling
|
|
108
|
+
activationConstraint: { delay: 250, tolerance: 8 },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
97
112
|
// Mutations
|
|
98
113
|
const createSystemMutation = catalogClient.createSystem.useMutation({
|
|
99
114
|
onSuccess: () => {
|
|
@@ -175,9 +190,10 @@ export const CatalogConfigPage = () => {
|
|
|
175
190
|
});
|
|
176
191
|
|
|
177
192
|
const addSystemToGroupMutation = catalogClient.addSystemToGroup.useMutation({
|
|
178
|
-
onSuccess: () => {
|
|
193
|
+
onSuccess: (_data, variables) => {
|
|
179
194
|
toast.success("System added to group successfully");
|
|
180
|
-
|
|
195
|
+
setRecentlyAdded({ systemId: variables.systemId, groupId: variables.groupId });
|
|
196
|
+
setTimeout(() => setRecentlyAdded(undefined), 1500);
|
|
181
197
|
void refetchGroups();
|
|
182
198
|
},
|
|
183
199
|
onError: (error) => {
|
|
@@ -244,12 +260,8 @@ export const CatalogConfigPage = () => {
|
|
|
244
260
|
});
|
|
245
261
|
};
|
|
246
262
|
|
|
247
|
-
const handleAddSystemToGroup = () => {
|
|
248
|
-
|
|
249
|
-
addSystemToGroupMutation.mutate({
|
|
250
|
-
groupId: selectedGroupId,
|
|
251
|
-
systemId: selectedSystemToAdd,
|
|
252
|
-
});
|
|
263
|
+
const handleAddSystemToGroup = (systemId: string, groupId: string) => {
|
|
264
|
+
addSystemToGroupMutation.mutate({ groupId, systemId });
|
|
253
265
|
};
|
|
254
266
|
|
|
255
267
|
const handleRemoveSystemFromGroup = (groupId: string, systemId: string) => {
|
|
@@ -260,10 +272,49 @@ export const CatalogConfigPage = () => {
|
|
|
260
272
|
updateGroupMutation.mutate({ id, data: { name: newName } });
|
|
261
273
|
};
|
|
262
274
|
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
(
|
|
266
|
-
|
|
275
|
+
// DnD event handlers
|
|
276
|
+
const handleDragStart = ({ active }: DragStartEvent) => {
|
|
277
|
+
setActiveSystemId(String(active.id));
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const handleDragOver = ({ over }: DragOverEvent) => {
|
|
281
|
+
setOverGroupId(over ? String(over.id) : undefined);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
|
285
|
+
setActiveSystemId(undefined);
|
|
286
|
+
setOverGroupId(undefined);
|
|
287
|
+
|
|
288
|
+
if (!over) return;
|
|
289
|
+
|
|
290
|
+
const systemId = String(active.id);
|
|
291
|
+
const groupId = String(over.id);
|
|
292
|
+
const targetGroup = groups.find((g) => g.id === groupId);
|
|
293
|
+
|
|
294
|
+
// Block if already assigned to this group
|
|
295
|
+
if (targetGroup?.systemIds?.includes(systemId)) return;
|
|
296
|
+
|
|
297
|
+
handleAddSystemToGroup(systemId, groupId);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const handleDragCancel = () => {
|
|
301
|
+
setActiveSystemId(undefined);
|
|
302
|
+
setOverGroupId(undefined);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const activeSystem = activeSystemId
|
|
306
|
+
? systems.find((s) => s.id === activeSystemId)
|
|
307
|
+
: undefined;
|
|
308
|
+
|
|
309
|
+
// Build a map of systemId → groupIds for quick lookup
|
|
310
|
+
const systemGroupMap = new Map<string, string[]>();
|
|
311
|
+
for (const group of groups) {
|
|
312
|
+
for (const sysId of group.systemIds ?? []) {
|
|
313
|
+
const existing = systemGroupMap.get(sysId) ?? [];
|
|
314
|
+
existing.push(group.id);
|
|
315
|
+
systemGroupMap.set(sysId, existing);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
267
318
|
|
|
268
319
|
return (
|
|
269
320
|
<PageLayout
|
|
@@ -273,213 +324,116 @@ export const CatalogConfigPage = () => {
|
|
|
273
324
|
loading={loading || accessLoading}
|
|
274
325
|
allowed={canManage}
|
|
275
326
|
>
|
|
276
|
-
<
|
|
277
|
-
{
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
className="h-8 w-8 p-0"
|
|
323
|
-
onClick={() => {
|
|
324
|
-
setEditingSystem(system);
|
|
325
|
-
setIsSystemEditorOpen(true);
|
|
326
|
-
}}
|
|
327
|
-
>
|
|
328
|
-
<Edit className="w-4 h-4" />
|
|
329
|
-
</Button>
|
|
330
|
-
<Button
|
|
331
|
-
variant="ghost"
|
|
332
|
-
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
|
|
333
|
-
onClick={() => handleDeleteSystem(system.id)}
|
|
334
|
-
>
|
|
335
|
-
<Trash2 className="w-4 h-4" />
|
|
336
|
-
</Button>
|
|
337
|
-
</div>
|
|
338
|
-
</div>
|
|
339
|
-
))}
|
|
340
|
-
</div>
|
|
341
|
-
)}
|
|
342
|
-
</CardContent>
|
|
343
|
-
</Card>
|
|
344
|
-
|
|
345
|
-
{/* Groups Management */}
|
|
346
|
-
<Card>
|
|
347
|
-
<CardHeader>
|
|
348
|
-
<CardHeaderRow>
|
|
349
|
-
<CardTitle className="flex items-center gap-2">
|
|
350
|
-
<LayoutGrid className="w-5 h-5 text-muted-foreground" />
|
|
351
|
-
Groups
|
|
352
|
-
</CardTitle>
|
|
353
|
-
<Button size="sm" onClick={() => setIsGroupEditorOpen(true)}>
|
|
354
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
355
|
-
Add Group
|
|
356
|
-
</Button>
|
|
357
|
-
</CardHeaderRow>
|
|
358
|
-
</CardHeader>
|
|
359
|
-
<CardContent className="space-y-4">
|
|
360
|
-
{groups.length === 0 ? (
|
|
361
|
-
<EmptyState title="No groups created yet." />
|
|
362
|
-
) : (
|
|
363
|
-
<div className="space-y-2">
|
|
364
|
-
{groups.map((group) => (
|
|
365
|
-
<div
|
|
366
|
-
key={group.id}
|
|
367
|
-
className="p-3 bg-muted/30 rounded-lg border border-border space-y-2"
|
|
368
|
-
>
|
|
369
|
-
<div className="flex items-center justify-between">
|
|
370
|
-
<div className="flex-1">
|
|
371
|
-
<EditableText
|
|
372
|
-
value={group.name}
|
|
373
|
-
onSave={(newName) =>
|
|
374
|
-
handleUpdateGroupName(group.id, newName)
|
|
375
|
-
}
|
|
376
|
-
className="font-medium text-foreground"
|
|
377
|
-
/>
|
|
378
|
-
<p className="text-xs text-muted-foreground font-mono">
|
|
379
|
-
{group.id}
|
|
380
|
-
</p>
|
|
381
|
-
</div>
|
|
382
|
-
<Button
|
|
383
|
-
variant="ghost"
|
|
384
|
-
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
|
|
385
|
-
onClick={() => handleDeleteGroup(group.id)}
|
|
386
|
-
>
|
|
387
|
-
<Trash2 className="w-4 h-4" />
|
|
388
|
-
</Button>
|
|
389
|
-
</div>
|
|
390
|
-
|
|
391
|
-
{/* Systems in this group */}
|
|
392
|
-
{group.systemIds && group.systemIds.length > 0 && (
|
|
393
|
-
<div className="pl-4 space-y-1">
|
|
394
|
-
{group.systemIds
|
|
395
|
-
.map((sysId) => systems.find((s) => s.id === sysId))
|
|
396
|
-
.filter((sys): sys is System => !!sys)
|
|
397
|
-
.map((sys) => (
|
|
398
|
-
<div
|
|
399
|
-
key={sys.id}
|
|
400
|
-
className="flex items-center justify-between text-sm bg-background p-2 rounded border border-border"
|
|
401
|
-
>
|
|
402
|
-
<span className="text-foreground">
|
|
403
|
-
{sys.name}
|
|
404
|
-
</span>
|
|
405
|
-
<Button
|
|
406
|
-
variant="ghost"
|
|
407
|
-
className="text-destructive/60 hover:text-destructive h-6 w-6 p-0"
|
|
408
|
-
onClick={() =>
|
|
409
|
-
handleRemoveSystemFromGroup(group.id, sys.id)
|
|
410
|
-
}
|
|
411
|
-
>
|
|
412
|
-
<Trash2 className="w-3 h-3" />
|
|
413
|
-
</Button>
|
|
414
|
-
</div>
|
|
415
|
-
))}
|
|
416
|
-
</div>
|
|
417
|
-
)}
|
|
418
|
-
</div>
|
|
419
|
-
))}
|
|
420
|
-
</div>
|
|
421
|
-
)}
|
|
422
|
-
</CardContent>
|
|
423
|
-
</Card>
|
|
424
|
-
</div>
|
|
425
|
-
|
|
426
|
-
{/* Add System to Group Section */}
|
|
427
|
-
{groups.length > 0 && systems.length > 0 && (
|
|
428
|
-
<Card>
|
|
429
|
-
<CardHeader>
|
|
430
|
-
<CardTitle>Add System to Group</CardTitle>
|
|
431
|
-
</CardHeader>
|
|
432
|
-
<CardContent className="space-y-4">
|
|
433
|
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
434
|
-
<div className="space-y-2">
|
|
435
|
-
<Label>Select Group</Label>
|
|
436
|
-
<select
|
|
437
|
-
className="w-full flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
438
|
-
value={selectedGroupId}
|
|
439
|
-
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
440
|
-
setSelectedGroupId(e.target.value)
|
|
441
|
-
}
|
|
442
|
-
>
|
|
443
|
-
{groups.map((g) => (
|
|
444
|
-
<option key={g.id} value={g.id}>
|
|
445
|
-
{g.name}
|
|
446
|
-
</option>
|
|
447
|
-
))}
|
|
448
|
-
</select>
|
|
449
|
-
</div>
|
|
450
|
-
|
|
451
|
-
<div className="space-y-2">
|
|
452
|
-
<Label>Select System</Label>
|
|
453
|
-
<select
|
|
454
|
-
className="w-full flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
455
|
-
value={selectedSystemToAdd}
|
|
456
|
-
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
457
|
-
setSelectedSystemToAdd(e.target.value)
|
|
458
|
-
}
|
|
459
|
-
>
|
|
460
|
-
<option value="">Select a system</option>
|
|
461
|
-
{availableSystems.map((s) => (
|
|
462
|
-
<option key={s.id} value={s.id}>
|
|
463
|
-
{s.name}
|
|
464
|
-
</option>
|
|
327
|
+
<DndContext
|
|
328
|
+
sensors={sensors}
|
|
329
|
+
onDragStart={handleDragStart}
|
|
330
|
+
onDragOver={handleDragOver}
|
|
331
|
+
onDragEnd={handleDragEnd}
|
|
332
|
+
onDragCancel={handleDragCancel}
|
|
333
|
+
>
|
|
334
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
335
|
+
{/* Systems Management */}
|
|
336
|
+
<Card>
|
|
337
|
+
<CardHeader>
|
|
338
|
+
<CardHeaderRow>
|
|
339
|
+
<CardTitle className="flex items-center gap-2">
|
|
340
|
+
<Server className="w-5 h-5 text-muted-foreground" />
|
|
341
|
+
Systems
|
|
342
|
+
</CardTitle>
|
|
343
|
+
<Button size="sm" onClick={() => setIsSystemEditorOpen(true)}>
|
|
344
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
345
|
+
Add System
|
|
346
|
+
</Button>
|
|
347
|
+
</CardHeaderRow>
|
|
348
|
+
{systems.length > 0 && groups.length > 0 && (
|
|
349
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
350
|
+
Drag a system onto a group, or use the{" "}
|
|
351
|
+
<FolderPlus className="w-3 h-3 inline" /> button to assign it.
|
|
352
|
+
</p>
|
|
353
|
+
)}
|
|
354
|
+
</CardHeader>
|
|
355
|
+
<CardContent className="space-y-4">
|
|
356
|
+
{systems.length === 0 ? (
|
|
357
|
+
<EmptyState title="No systems created yet." />
|
|
358
|
+
) : (
|
|
359
|
+
<div className="space-y-2">
|
|
360
|
+
{systems.map((system) => (
|
|
361
|
+
<DraggableSystem
|
|
362
|
+
key={system.id}
|
|
363
|
+
system={system}
|
|
364
|
+
groups={groups}
|
|
365
|
+
assignedGroupIds={systemGroupMap.get(system.id) ?? []}
|
|
366
|
+
onEdit={(s) => {
|
|
367
|
+
setEditingSystem(s);
|
|
368
|
+
setIsSystemEditorOpen(true);
|
|
369
|
+
}}
|
|
370
|
+
onDelete={handleDeleteSystem}
|
|
371
|
+
onAddToGroup={handleAddSystemToGroup}
|
|
372
|
+
/>
|
|
465
373
|
))}
|
|
466
|
-
</
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</CardContent>
|
|
377
|
+
</Card>
|
|
378
|
+
|
|
379
|
+
{/* Groups Management */}
|
|
380
|
+
<Card>
|
|
381
|
+
<CardHeader>
|
|
382
|
+
<CardHeaderRow>
|
|
383
|
+
<CardTitle className="flex items-center gap-2">
|
|
384
|
+
<LayoutGrid className="w-5 h-5 text-muted-foreground" />
|
|
385
|
+
Groups
|
|
386
|
+
</CardTitle>
|
|
387
|
+
<Button size="sm" onClick={() => setIsGroupEditorOpen(true)}>
|
|
475
388
|
<Plus className="w-4 h-4 mr-2" />
|
|
476
|
-
Add
|
|
389
|
+
Add Group
|
|
477
390
|
</Button>
|
|
478
|
-
</
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
391
|
+
</CardHeaderRow>
|
|
392
|
+
{groups.length > 0 && systems.length > 0 && (
|
|
393
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
394
|
+
Drop systems here to assign them to a group.
|
|
395
|
+
</p>
|
|
396
|
+
)}
|
|
397
|
+
</CardHeader>
|
|
398
|
+
<CardContent className="space-y-4">
|
|
399
|
+
{groups.length === 0 ? (
|
|
400
|
+
<EmptyState title="No groups created yet." />
|
|
401
|
+
) : (
|
|
402
|
+
<div className="space-y-2">
|
|
403
|
+
{groups.map((group) => (
|
|
404
|
+
<DroppableGroup
|
|
405
|
+
key={group.id}
|
|
406
|
+
group={group}
|
|
407
|
+
systems={systems}
|
|
408
|
+
isOver={overGroupId === group.id}
|
|
409
|
+
isDragging={activeSystemId !== undefined}
|
|
410
|
+
draggingSystemAlreadyInGroup={
|
|
411
|
+
activeSystemId !== undefined &&
|
|
412
|
+
(group.systemIds ?? []).includes(activeSystemId)
|
|
413
|
+
}
|
|
414
|
+
newlyAddedSystemId={
|
|
415
|
+
recentlyAdded?.groupId === group.id
|
|
416
|
+
? recentlyAdded.systemId
|
|
417
|
+
: undefined
|
|
418
|
+
}
|
|
419
|
+
onDeleteGroup={handleDeleteGroup}
|
|
420
|
+
onUpdateGroupName={handleUpdateGroupName}
|
|
421
|
+
onRemoveSystem={handleRemoveSystemFromGroup}
|
|
422
|
+
/>
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
</CardContent>
|
|
427
|
+
</Card>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
{/* Drag overlay — the floating ghost shown while dragging */}
|
|
431
|
+
{/* dropAnimation must be null (not undefined) per @dnd-kit API to disable the fly-back animation */}
|
|
432
|
+
{/* eslint-disable-next-line unicorn/no-null */}
|
|
433
|
+
<DragOverlay dropAnimation={null}>
|
|
434
|
+
{activeSystem ? <SystemDragOverlay system={activeSystem} /> : undefined}
|
|
435
|
+
</DragOverlay>
|
|
436
|
+
</DndContext>
|
|
483
437
|
|
|
484
438
|
{/* Dialogs */}
|
|
485
439
|
<SystemEditor
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useDraggable } from "@dnd-kit/core";
|
|
3
|
+
import { GripVertical, Edit, Trash2, FolderPlus } from "lucide-react";
|
|
4
|
+
import { Button } from "@checkstack/ui";
|
|
5
|
+
import { ExtensionSlot } from "@checkstack/frontend-api";
|
|
6
|
+
import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
|
|
7
|
+
import type { Group, System } from "../api";
|
|
8
|
+
|
|
9
|
+
interface DraggableSystemProps {
|
|
10
|
+
system: System;
|
|
11
|
+
groups: Group[];
|
|
12
|
+
assignedGroupIds: string[];
|
|
13
|
+
onEdit: (system: System) => void;
|
|
14
|
+
onDelete: (id: string) => void;
|
|
15
|
+
onAddToGroup: (systemId: string, groupId: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A draggable system card for the Catalog Management page.
|
|
20
|
+
*
|
|
21
|
+
* Layout: two-row design so the system name is never truncated.
|
|
22
|
+
* Row 1: grip handle + full-width name + description
|
|
23
|
+
* Row 2 (footer): extension slot (Health Checks etc.) + action buttons
|
|
24
|
+
*
|
|
25
|
+
* On touch / small screens, the + button opens an inline group picker
|
|
26
|
+
* since drag-and-drop can be imprecise on small viewports.
|
|
27
|
+
*
|
|
28
|
+
* We intentionally do NOT apply the useDraggable transform to the element —
|
|
29
|
+
* DragOverlay handles the moving visual, keeping the original in-place.
|
|
30
|
+
*/
|
|
31
|
+
export const DraggableSystem = ({
|
|
32
|
+
system,
|
|
33
|
+
groups,
|
|
34
|
+
assignedGroupIds,
|
|
35
|
+
onEdit,
|
|
36
|
+
onDelete,
|
|
37
|
+
onAddToGroup,
|
|
38
|
+
}: DraggableSystemProps) => {
|
|
39
|
+
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
40
|
+
|
|
41
|
+
const { attributes, listeners, setNodeRef } = useDraggable({
|
|
42
|
+
id: system.id,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const availableGroups = groups.filter((g) => !assignedGroupIds.includes(g.id));
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
ref={setNodeRef}
|
|
50
|
+
className="bg-muted/30 rounded-lg border border-border transition-all duration-150"
|
|
51
|
+
>
|
|
52
|
+
{/* Main row: grip + name/description */}
|
|
53
|
+
<div className="flex items-start gap-2 p-3 pb-2">
|
|
54
|
+
{/* Grip handle — only this element triggers the drag */}
|
|
55
|
+
<div
|
|
56
|
+
{...listeners}
|
|
57
|
+
{...attributes}
|
|
58
|
+
className="flex-shrink-0 mt-0.5 cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground touch-none"
|
|
59
|
+
aria-label={`Drag ${system.name}`}
|
|
60
|
+
>
|
|
61
|
+
<GripVertical className="w-4 h-4" />
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Name + description — gets all remaining width, never truncated */}
|
|
65
|
+
<div className="flex-1 min-w-0">
|
|
66
|
+
<p className="font-medium text-foreground leading-snug">
|
|
67
|
+
{system.name}
|
|
68
|
+
</p>
|
|
69
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
70
|
+
{system.description || "No description"}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Footer row: unassigned badge (left) + all action buttons (right) */}
|
|
76
|
+
<div className="flex items-center justify-between gap-2 px-3 pb-2 pl-9">
|
|
77
|
+
{/* Left: unassigned badge */}
|
|
78
|
+
<div className="min-w-0">
|
|
79
|
+
{assignedGroupIds.length === 0 && (
|
|
80
|
+
<span className="flex-shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-warning/15 text-warning-foreground font-mono">
|
|
81
|
+
unassigned
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Right: extension slot + action buttons, all grouped together */}
|
|
87
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
88
|
+
<ExtensionSlot
|
|
89
|
+
slot={CatalogSystemActionsSlot}
|
|
90
|
+
context={{ systemId: system.id, systemName: system.name }}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
{/* Group picker */}
|
|
94
|
+
<div className="relative">
|
|
95
|
+
<Button
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="sm"
|
|
98
|
+
className="h-7 w-7 p-0"
|
|
99
|
+
title={`Add ${system.name} to a group`}
|
|
100
|
+
onClick={() => setIsPickerOpen((v) => !v)}
|
|
101
|
+
disabled={availableGroups.length === 0}
|
|
102
|
+
aria-expanded={isPickerOpen}
|
|
103
|
+
aria-haspopup="listbox"
|
|
104
|
+
aria-label={`Add ${system.name} to group`}
|
|
105
|
+
>
|
|
106
|
+
<FolderPlus className="w-3.5 h-3.5" />
|
|
107
|
+
</Button>
|
|
108
|
+
|
|
109
|
+
{isPickerOpen && availableGroups.length > 0 && (
|
|
110
|
+
<>
|
|
111
|
+
{/* Backdrop */}
|
|
112
|
+
<div
|
|
113
|
+
className="fixed inset-0 z-40"
|
|
114
|
+
onClick={() => setIsPickerOpen(false)}
|
|
115
|
+
/>
|
|
116
|
+
<div
|
|
117
|
+
role="listbox"
|
|
118
|
+
aria-label="Select a group"
|
|
119
|
+
className="absolute right-0 bottom-full mb-1 z-50 min-w-[160px] rounded-md border border-border bg-popover shadow-md py-1"
|
|
120
|
+
>
|
|
121
|
+
{availableGroups.map((group) => (
|
|
122
|
+
<button
|
|
123
|
+
key={group.id}
|
|
124
|
+
role="option"
|
|
125
|
+
aria-selected={false}
|
|
126
|
+
className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
|
|
127
|
+
onClick={() => {
|
|
128
|
+
onAddToGroup(system.id, group.id);
|
|
129
|
+
setIsPickerOpen(false);
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{group.name}
|
|
133
|
+
</button>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<Button
|
|
141
|
+
variant="ghost"
|
|
142
|
+
size="sm"
|
|
143
|
+
className="h-7 w-7 p-0"
|
|
144
|
+
onClick={() => onEdit(system)}
|
|
145
|
+
aria-label={`Edit ${system.name}`}
|
|
146
|
+
>
|
|
147
|
+
<Edit className="w-3.5 h-3.5" />
|
|
148
|
+
</Button>
|
|
149
|
+
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="sm"
|
|
153
|
+
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-7 w-7 p-0"
|
|
154
|
+
onClick={() => onDelete(system.id)}
|
|
155
|
+
aria-label={`Delete ${system.name}`}
|
|
156
|
+
>
|
|
157
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A non-interactive ghost card shown in the DragOverlay while dragging.
|
|
167
|
+
*/
|
|
168
|
+
export const SystemDragOverlay = ({ system }: { system: System }) => (
|
|
169
|
+
<div className="flex items-center gap-2 p-3 bg-card rounded-lg border border-primary/50 shadow-lg cursor-grabbing w-full">
|
|
170
|
+
<GripVertical className="w-4 h-4 text-muted-foreground/40 flex-shrink-0" />
|
|
171
|
+
<div className="flex-1 min-w-0">
|
|
172
|
+
<p className="font-medium text-foreground">{system.name}</p>
|
|
173
|
+
<p className="text-xs text-muted-foreground">
|
|
174
|
+
{system.description || "No description"}
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useDroppable } from "@dnd-kit/core";
|
|
2
|
+
import { EditableText, Button } from "@checkstack/ui";
|
|
3
|
+
import { Trash2 } from "lucide-react";
|
|
4
|
+
import type { Group, System } from "../api";
|
|
5
|
+
|
|
6
|
+
interface DroppableGroupProps {
|
|
7
|
+
group: Group;
|
|
8
|
+
systems: System[];
|
|
9
|
+
isOver: boolean;
|
|
10
|
+
isDragging: boolean;
|
|
11
|
+
draggingSystemAlreadyInGroup: boolean;
|
|
12
|
+
newlyAddedSystemId?: string;
|
|
13
|
+
onDeleteGroup: (id: string) => void;
|
|
14
|
+
onUpdateGroupName: (id: string, name: string) => void;
|
|
15
|
+
onRemoveSystem: (groupId: string, systemId: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A droppable group card for the Catalog Management page.
|
|
20
|
+
*
|
|
21
|
+
* When a system is being dragged over, the card highlights. If the system
|
|
22
|
+
* is already assigned to this group, the drop zone shows a "Already in group"
|
|
23
|
+
* indicator and the drop is blocked visually.
|
|
24
|
+
*/
|
|
25
|
+
export const DroppableGroup = ({
|
|
26
|
+
group,
|
|
27
|
+
systems,
|
|
28
|
+
isOver,
|
|
29
|
+
isDragging,
|
|
30
|
+
draggingSystemAlreadyInGroup,
|
|
31
|
+
newlyAddedSystemId,
|
|
32
|
+
onDeleteGroup,
|
|
33
|
+
onUpdateGroupName,
|
|
34
|
+
onRemoveSystem,
|
|
35
|
+
}: DroppableGroupProps) => {
|
|
36
|
+
const { setNodeRef } = useDroppable({ id: group.id });
|
|
37
|
+
|
|
38
|
+
const groupSystems = (group.systemIds ?? [])
|
|
39
|
+
.map((sysId) => systems.find((s) => s.id === sysId))
|
|
40
|
+
.filter((sys): sys is System => !!sys);
|
|
41
|
+
|
|
42
|
+
const dropzoneActive = isDragging;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={setNodeRef}
|
|
47
|
+
className={`p-3 rounded-lg border space-y-2 transition-all duration-150 ${
|
|
48
|
+
isOver && !draggingSystemAlreadyInGroup
|
|
49
|
+
? "border-primary bg-primary/5 shadow-md shadow-primary/10"
|
|
50
|
+
: isOver && draggingSystemAlreadyInGroup
|
|
51
|
+
? "border-muted-foreground/40 bg-muted/10"
|
|
52
|
+
: dropzoneActive
|
|
53
|
+
? "border-border/60 bg-muted/20 border-dashed"
|
|
54
|
+
: "border-border bg-muted/30"
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
{/* Group header */}
|
|
58
|
+
<div className="flex items-center justify-between">
|
|
59
|
+
<div className="flex-1">
|
|
60
|
+
<EditableText
|
|
61
|
+
value={group.name}
|
|
62
|
+
onSave={(newName) => onUpdateGroupName(group.id, newName)}
|
|
63
|
+
className="font-medium text-foreground"
|
|
64
|
+
/>
|
|
65
|
+
<p className="text-xs text-muted-foreground font-mono">{group.id}</p>
|
|
66
|
+
</div>
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
|
|
70
|
+
onClick={() => onDeleteGroup(group.id)}
|
|
71
|
+
aria-label={`Delete group ${group.name}`}
|
|
72
|
+
>
|
|
73
|
+
<Trash2 className="w-4 h-4" />
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Drop zone label */}
|
|
78
|
+
{isOver && (
|
|
79
|
+
<div
|
|
80
|
+
className={`text-xs text-center py-1 rounded transition-colors ${
|
|
81
|
+
draggingSystemAlreadyInGroup
|
|
82
|
+
? "text-muted-foreground"
|
|
83
|
+
: "text-primary font-medium"
|
|
84
|
+
}`}
|
|
85
|
+
>
|
|
86
|
+
{draggingSystemAlreadyInGroup
|
|
87
|
+
? "Already in this group"
|
|
88
|
+
: "Drop to add"}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Systems in this group */}
|
|
93
|
+
{groupSystems.length > 0 ? (
|
|
94
|
+
<div className="pl-2 space-y-1">
|
|
95
|
+
{groupSystems.map((sys) => {
|
|
96
|
+
const isNew = newlyAddedSystemId === sys.id;
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
key={sys.id}
|
|
100
|
+
className={`flex items-center justify-between text-sm bg-background p-2 rounded border transition-all duration-700 ${
|
|
101
|
+
isNew
|
|
102
|
+
? "border-green-500/60 shadow-sm shadow-green-500/30"
|
|
103
|
+
: "border-border shadow-none"
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
<span className="text-foreground truncate">{sys.name}</span>
|
|
107
|
+
<Button
|
|
108
|
+
variant="ghost"
|
|
109
|
+
className="text-destructive/60 hover:text-destructive h-6 w-6 p-0 flex-shrink-0"
|
|
110
|
+
onClick={() => onRemoveSystem(group.id, sys.id)}
|
|
111
|
+
aria-label={`Remove ${sys.name} from ${group.name}`}
|
|
112
|
+
>
|
|
113
|
+
<Trash2 className="w-3 h-3" />
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
/* Empty drop zone hint */
|
|
121
|
+
<div
|
|
122
|
+
className={`text-xs text-center py-3 rounded border border-dashed transition-colors ${
|
|
123
|
+
dropzoneActive
|
|
124
|
+
? "border-primary/40 text-primary/60"
|
|
125
|
+
: "border-border text-muted-foreground/60"
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
{dropzoneActive ? "Drag a system here" : "No systems assigned"}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
};
|