Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 02:05:13 +00:00
co-authored by renee-png
parent 00c484d102
commit 6224ddd812
@@ -0,0 +1,252 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus, ArrowUp, ArrowDown } from "lucide-react";
import type { FormFieldDef, FormFieldType } from "@/lib/forms-shared";
import { toast } from "sonner";
const TYPE_LABEL: Record<FormFieldType, string> = {
text: "Text",
textarea: "Long text",
number: "Number",
date: "Date",
dropdown: "Dropdown",
county: "Florida County",
client: "Client / Association",
homeowner: "Homeowner",
};
function slug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 40);
}
export function FormFieldDefsDialog({
open,
onOpenChange,
fields,
onChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
fields: FormFieldDef[];
onChange: (next: FormFieldDef[]) => void;
}) {
const [draft, setDraft] = useState<FormFieldDef[]>(fields);
// re-sync when reopened
const reset = () => setDraft(fields);
const update = (i: number, patch: Partial<FormFieldDef>) => {
setDraft((prev) => prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
};
const remove = (i: number) => setDraft((prev) => prev.filter((_, idx) => idx !== i));
const move = (i: number, dir: -1 | 1) => {
setDraft((prev) => {
const next = [...prev];
const j = i + dir;
if (j < 0 || j >= next.length) return prev;
[next[i], next[j]] = [next[j], next[i]];
return next;
});
};
const add = () => {
const base = "field";
let n = 1;
const used = new Set(draft.map((d) => d.key));
while (used.has(`${base}_${n}`)) n++;
setDraft((prev) => [
...prev,
{ key: `${base}_${n}`, label: `Field ${n}`, type: "text", required: false },
]);
};
const save = () => {
// de-dupe + slug keys
const seen = new Set<string>();
for (const f of draft) {
if (!f.label.trim()) {
toast.error("Each field needs a label");
return;
}
const key = slug(f.key || f.label);
if (!key) {
toast.error(`Invalid key for "${f.label}"`);
return;
}
if (seen.has(key)) {
toast.error(`Duplicate field key: ${key}`);
return;
}
seen.add(key);
f.key = key;
}
onChange(draft);
onOpenChange(false);
toast.success("Fields saved");
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (v) reset();
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Form fillable fields</DialogTitle>
<DialogDescription>
Define inputs that will appear when generating this form. Insert them in the body using{" "}
<code className="text-xs">{"{{field.key}}"}</code>.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-2">
<div className="space-y-3">
{draft.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No fields yet. Add one to start collecting input.
</p>
)}
{draft.map((f, i) => (
<div key={i} className="border rounded-md p-3 space-y-2 bg-muted/20">
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_180px_auto] gap-2 items-end">
<div>
<Label className="text-xs">Label</Label>
<Input
value={f.label}
onChange={(e) => update(i, { label: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">
Key <span className="text-muted-foreground">(token: {`{{field.${f.key || "..."}}}`})</span>
</Label>
<Input
value={f.key}
onChange={(e) => update(i, { key: e.target.value })}
onBlur={(e) => update(i, { key: slug(e.target.value || f.label) })}
/>
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={f.type}
onValueChange={(v) => update(i, { type: v as FormFieldType })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABEL) as FormFieldType[]).map((t) => (
<SelectItem key={t} value={t}>
{TYPE_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => move(i, -1)} title="Move up">
<ArrowUp className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => move(i, 1)} title="Move down">
<ArrowDown className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => remove(i)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{f.type === "dropdown" && (
<div>
<Label className="text-xs">Options (one per line)</Label>
<Textarea
rows={3}
value={(f.options ?? []).join("\n")}
onChange={(e) =>
update(i, {
options: e.target.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder={"Yes\nNo\nN/A"}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end">
<div>
<Label className="text-xs">Placeholder</Label>
<Input
value={f.placeholder ?? ""}
onChange={(e) => update(i, { placeholder: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Default value</Label>
<Input
value={f.default ?? ""}
onChange={(e) => update(i, { default: e.target.value })}
/>
</div>
<div className="flex items-center gap-2 pb-2">
<Switch
checked={!!f.required}
onCheckedChange={(v) => update(i, { required: v })}
id={`req-${i}`}
/>
<Label htmlFor={`req-${i}`} className="cursor-pointer text-xs">
Required
</Label>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
<DialogFooter className="flex-wrap gap-2">
<Button variant="outline" onClick={add}>
<Plus className="h-4 w-4 mr-1.5" /> Add field
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={save}>Save fields</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}