Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user