Fix drag-and-drop reordering for custom fields in content type editor (#108)
Wire up @dnd-kit sortable integration in ContentTypeEditor and connect the existing reorderFields API. The grab handle icon was visual-only with no drag functionality attached. Closes #43 Co-authored-by: Matt Kane <mkane@cloudflare.com>
This commit is contained in:
@@ -1,4 +1,21 @@
|
|||||||
import { Badge, Button, Input, InputArea, Label, Select, buttonVariants } from "@cloudflare/kumo";
|
import { Badge, Button, Input, InputArea, Label, Select, buttonVariants } from "@cloudflare/kumo";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -114,7 +131,7 @@ export function ContentTypeEditor({
|
|||||||
onAddField,
|
onAddField,
|
||||||
onUpdateField,
|
onUpdateField,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
onReorderFields: _onReorderFields,
|
onReorderFields,
|
||||||
}: ContentTypeEditorProps) {
|
}: ContentTypeEditorProps) {
|
||||||
const _navigate = useNavigate();
|
const _navigate = useNavigate();
|
||||||
|
|
||||||
@@ -268,6 +285,21 @@ export function ContentTypeEditor({
|
|||||||
const isFromCode = collection?.source === "code";
|
const isFromCode = collection?.source === "code";
|
||||||
const fields = collection?.fields ?? [];
|
const fields = collection?.fields ?? [];
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = fields.findIndex((f) => f.id === active.id);
|
||||||
|
const newIndex = fields.findIndex((f) => f.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
const reordered = arrayMove(fields, oldIndex, newIndex);
|
||||||
|
onReorderFields?.(reordered.map((f) => f.slug));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -561,17 +593,28 @@ export function ContentTypeEditor({
|
|||||||
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider border-b">
|
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider border-b">
|
||||||
Custom Fields
|
Custom Fields
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y">
|
<DndContext
|
||||||
{fields.map((field) => (
|
sensors={sensors}
|
||||||
<FieldRow
|
collisionDetection={closestCenter}
|
||||||
key={field.id}
|
onDragEnd={handleDragEnd}
|
||||||
field={field}
|
>
|
||||||
isFromCode={isFromCode}
|
<SortableContext
|
||||||
onEdit={() => handleEditField(field)}
|
items={fields.map((f) => f.id)}
|
||||||
onDelete={() => setDeleteFieldTarget(field)}
|
strategy={verticalListSortingStrategy}
|
||||||
/>
|
>
|
||||||
))}
|
<div className="divide-y">
|
||||||
</div>
|
{fields.map((field) => (
|
||||||
|
<FieldRow
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
isFromCode={isFromCode}
|
||||||
|
onEdit={() => handleEditField(field)}
|
||||||
|
onDelete={() => setDeleteFieldTarget(field)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -620,9 +663,31 @@ interface FieldRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) {
|
function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: field.id,
|
||||||
|
disabled: isFromCode,
|
||||||
|
});
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center px-4 py-3 hover:bg-kumo-tint/25">
|
<div
|
||||||
{!isFromCode && <DotsSixVertical className="h-5 w-5 mr-3 text-kumo-subtle cursor-grab" />}
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-4 py-3 hover:bg-kumo-tint/25",
|
||||||
|
isDragging && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isFromCode && (
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing mr-3"
|
||||||
|
aria-label={`Drag to reorder ${field.label}`}
|
||||||
|
>
|
||||||
|
<DotsSixVertical className="h-5 w-5 text-kumo-subtle" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="font-medium">{field.label}</span>
|
<span className="font-medium">{field.label}</span>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
createField,
|
createField,
|
||||||
updateField,
|
updateField,
|
||||||
deleteField,
|
deleteField,
|
||||||
|
reorderFields,
|
||||||
fetchOrphanedTables,
|
fetchOrphanedTables,
|
||||||
registerOrphanedTable,
|
registerOrphanedTable,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
@@ -1456,6 +1457,16 @@ function ContentTypesEditPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reorderFieldsMutation = useMutation({
|
||||||
|
mutationFn: (fieldSlugs: string[]) => reorderFields(slug, fieldSlugs),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["schema", "collections", slug],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorScreen error={error.message} />;
|
return <ErrorScreen error={error.message} />;
|
||||||
}
|
}
|
||||||
@@ -1472,6 +1483,7 @@ function ContentTypesEditPage() {
|
|||||||
onAddField={(input) => addFieldMutation.mutateAsync(input)}
|
onAddField={(input) => addFieldMutation.mutateAsync(input)}
|
||||||
onUpdateField={(fieldSlug, input) => updateFieldMutation.mutateAsync({ fieldSlug, input })}
|
onUpdateField={(fieldSlug, input) => updateFieldMutation.mutateAsync({ fieldSlug, input })}
|
||||||
onDeleteField={(fieldSlug) => deleteFieldMutation.mutate(fieldSlug)}
|
onDeleteField={(fieldSlug) => deleteFieldMutation.mutate(fieldSlug)}
|
||||||
|
onReorderFields={(fieldSlugs) => reorderFieldsMutation.mutate(fieldSlugs)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user