ubiquitous-invention/apps/web/components/forms/form-field-card.tsx

183 lines
4.8 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}