@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 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.4.2",
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.0.0",
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 React, { useState, useEffect } from "react";
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, Trash2, LayoutGrid, Server, Edit } from "lucide-react";
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
- setSelectedSystemToAdd("");
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
- if (!selectedGroupId || !selectedSystemToAdd) return;
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
- const selectedGroup = groups.find((g) => g.id === selectedGroupId);
264
- const availableSystems = systems.filter(
265
- (s) => !selectedGroup?.systemIds?.includes(s.id),
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
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
277
- {/* Systems Management */}
278
- <Card>
279
- <CardHeader>
280
- <CardHeaderRow>
281
- <CardTitle className="flex items-center gap-2">
282
- <Server className="w-5 h-5 text-muted-foreground" />
283
- Systems
284
- </CardTitle>
285
- <Button size="sm" onClick={() => setIsSystemEditorOpen(true)}>
286
- <Plus className="w-4 h-4 mr-2" />
287
- Add System
288
- </Button>
289
- </CardHeaderRow>
290
- </CardHeader>
291
- <CardContent className="space-y-4">
292
- {systems.length === 0 ? (
293
- <EmptyState title="No systems created yet." />
294
- ) : (
295
- <div className="space-y-2">
296
- {systems.map((system) => (
297
- <div
298
- key={system.id}
299
- className="flex items-start justify-between p-3 bg-muted/30 rounded-lg border border-border"
300
- >
301
- <div className="flex-1 space-y-1">
302
- <div className="flex items-center justify-between">
303
- <span className="font-medium text-foreground">
304
- {system.name}
305
- </span>
306
- <ExtensionSlot
307
- slot={CatalogSystemActionsSlot}
308
- context={{
309
- systemId: system.id,
310
- systemName: system.name,
311
- }}
312
- />
313
- </div>
314
- <p className="text-xs text-muted-foreground">
315
- {system.description || "No description"}
316
- </p>
317
- </div>
318
- <div className="flex gap-1">
319
- <Button
320
- variant="ghost"
321
- size="sm"
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
- </select>
467
- </div>
468
-
469
- <div className="flex items-end">
470
- <Button
471
- onClick={handleAddSystemToGroup}
472
- disabled={!selectedSystemToAdd}
473
- className="w-full"
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 to Group
389
+ Add Group
477
390
  </Button>
478
- </div>
479
- </div>
480
- </CardContent>
481
- </Card>
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
+ };