183 lines
4.8 KiB
TypeScript
183 lines
4.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useSortable } from "@dnd-kit/sortable";
|
||
|
|
import { CSS } from "@dnd-kit/utilities";
|
||
|
|
import { GripVertical, Trash2 } from "lucide-react";
|
||
|
|
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
type FormField = {
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
type: string;
|
||
|
|
required: boolean;
|
||
|
|
placeholder?: string;
|
||
|
|
helpText?: string;
|
||
|
|
options?: { label: string; value: string }[];
|
||
|
|
defaultValue?: unknown;
|
||
|
|
mappedProperty: string | null;
|
||
|
|
validation?: {
|
||
|
|
min?: number;
|
||
|
|
max?: number;
|
||
|
|
pattern?: string;
|
||
|
|
maxLength?: number;
|
||
|
|
};
|
||
|
|
conditionals?: {
|
||
|
|
fieldId: string;
|
||
|
|
operator: string;
|
||
|
|
value: unknown;
|
||
|
|
action: string;
|
||
|
|
}[];
|
||
|
|
};
|
||
|
|
|
||
|
|
function formatTypeLabel(type: string) {
|
||
|
|
return type
|
||
|
|
.split("_")
|
||
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||
|
|
.join(" ");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FormFieldCard({
|
||
|
|
field,
|
||
|
|
isSelected,
|
||
|
|
onSelect,
|
||
|
|
onChange,
|
||
|
|
onDelete,
|
||
|
|
}: {
|
||
|
|
field: FormField;
|
||
|
|
isSelected: boolean;
|
||
|
|
onSelect: () => void;
|
||
|
|
onChange: (patch: Partial<FormField>) => void;
|
||
|
|
onDelete: () => void;
|
||
|
|
}) {
|
||
|
|
const {
|
||
|
|
attributes,
|
||
|
|
listeners,
|
||
|
|
setNodeRef,
|
||
|
|
transform,
|
||
|
|
transition,
|
||
|
|
isDragging,
|
||
|
|
} = useSortable({ id: field.id });
|
||
|
|
|
||
|
|
const style = {
|
||
|
|
transform: CSS.Transform.toString(transform),
|
||
|
|
transition,
|
||
|
|
};
|
||
|
|
|
||
|
|
const [editingLabel, setEditingLabel] = useState(false);
|
||
|
|
const [labelDraft, setLabelDraft] = useState(field.label);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setLabelDraft(field.label);
|
||
|
|
}, [field.label]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={setNodeRef}
|
||
|
|
style={style}
|
||
|
|
className={cn(
|
||
|
|
"flex items-stretch gap-2 rounded-lg border bg-card p-2 shadow-sm transition-shadow",
|
||
|
|
isSelected && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||
|
|
isDragging && "z-10 opacity-90 shadow-md",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex shrink-0 cursor-grab touch-none items-center rounded-md border border-transparent px-1 text-muted-foreground hover:bg-muted active:cursor-grabbing"
|
||
|
|
aria-label="Drag to reorder"
|
||
|
|
{...attributes}
|
||
|
|
{...listeners}
|
||
|
|
>
|
||
|
|
<GripVertical className="size-4" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div
|
||
|
|
role="button"
|
||
|
|
tabIndex={0}
|
||
|
|
className="min-w-0 flex-1 cursor-pointer text-left"
|
||
|
|
onClick={onSelect}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter" || e.key === " ") {
|
||
|
|
e.preventDefault();
|
||
|
|
onSelect();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{editingLabel ? (
|
||
|
|
<Input
|
||
|
|
autoFocus
|
||
|
|
value={labelDraft}
|
||
|
|
onChange={(e) => setLabelDraft(e.target.value)}
|
||
|
|
onBlur={() => {
|
||
|
|
setEditingLabel(false);
|
||
|
|
if (labelDraft.trim() !== field.label) {
|
||
|
|
onChange({ label: labelDraft.trim() || "Untitled" });
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||
|
|
e.stopPropagation();
|
||
|
|
}}
|
||
|
|
className="h-8"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="block w-full truncate text-left font-medium text-foreground hover:underline"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
setEditingLabel(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{field.label || "Untitled"}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||
|
|
<Badge variant="secondary" className="font-normal">
|
||
|
|
{formatTypeLabel(field.type)}
|
||
|
|
</Badge>
|
||
|
|
{field.required ? (
|
||
|
|
<Badge variant="outline" className="text-xs">
|
||
|
|
Required
|
||
|
|
</Badge>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex shrink-0 flex-col items-end justify-center gap-1">
|
||
|
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={field.required}
|
||
|
|
onChange={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onChange({ required: e.target.checked });
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
className="size-3.5 rounded border-input accent-primary"
|
||
|
|
/>
|
||
|
|
Req.
|
||
|
|
</label>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="icon"
|
||
|
|
variant="ghost"
|
||
|
|
className="size-8 text-muted-foreground hover:text-destructive"
|
||
|
|
aria-label="Delete field"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onDelete();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Trash2 className="size-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|