1"use client";
2
3
16import { useState } from "react";
17import { ArrowDown, ArrowUp, GripVertical, Link2, Plus, Save, Trash2, X } from "lucide-react";
18import { cn } from "@/lib/utils";
19import { toast } from "@/stores/ui-store";
20import { DatabaseProperty } from "@/hooks/use-database";
21import {
22 FormConfig,
23 FormDto,
24 FormOption,
25 FormQuestionInput,
26 FormQuestionType,
27 FormStatus,
28 FormValidation,
29 FORM_QUESTION_TYPES,
30 FORM_STATUSES,
31 questionOptions,
32 useUpdateForm
33} from "@/hooks/use-forms";
34import { FormField } from "@/components/form/form-field";
35
36
37const notify = (t: { title: string; description?: string; variant?: string }) =>
38 toast(t.variant === "destructive" ? "error" : "success", t.title, t.description);
39
40const QUESTION_TYPES: { type: FormQuestionType; label: string }[] = FORM_QUESTION_TYPES.map((t) => ({
41 type: t,
42 label: t.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
43}));
44
45const inputCls =
46 "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring";
47
48export interface FormBuilderViewProps {
49 form: FormDto;
50 properties: DatabaseProperty[];
51 databaseId: string;
52
53 workspaceId?: string;
54}
55
56
57type DraftQuestion = FormQuestionInput & { key: string };
58
59function keyFor(q: { id?: string }) {
60 return q.id ?? `dq_${Math.random().toString(36).slice(2, 10)}`;
61}
62
63function toInput(q: DraftQuestion): FormQuestionInput {
64 return {
65 type: q.type,
66 label: q.label,
67 description: q.description,
68 required: q.required,
69 propertyId: q.propertyId ?? null,
70 validation: q.validation ?? null,
71 placeholder: q.placeholder
72 };
73}
74
75
76function defaultTypeFor(propertyType?: string): FormQuestionType {
77 switch (propertyType) {
78 case "title":
79 case "text":
80 case "rich_text":
81 return "SHORT_TEXT";
82 case "number":
83 return "NUMBER";
84 case "email":
85 return "EMAIL";
86 case "date":
87 case "date_range":
88 return "DATE";
89 case "checkbox":
90 return "CHECKBOX";
91 case "select":
92 case "status":
93 return "SINGLE_SELECT";
94 case "multi_select":
95 return "MULTI_SELECT";
96 case "files":
97 return "FILE";
98 default:
99 return "SHORT_TEXT";
100 }
101}
102
103function optionsForProperty(prop?: DatabaseProperty): FormOption[] {
104 const opts = prop?.config?.options;
105 if (!opts || !Array.isArray(opts)) return [];
106 return opts.map((o) => ({ id: o.id, name: o.name, color: o.color }));
107}
108
109function setSelectOptions(input: FormQuestionInput, options: FormOption[]): FormQuestionInput {
110 const validation: FormValidation = { ...(input.validation ?? {}) };
111
112
113 const names = options.map((o) => o.name.trim()).filter((n) => n.length > 0);
114 if (names.length) validation.options = names;
115 else delete validation.options;
116 return { ...input, validation: Object.keys(validation).length ? validation : null };
117}
118
119export function FormBuilderView({ form, properties, databaseId, workspaceId }: FormBuilderViewProps) {
120
121
122 void workspaceId;
123 const updateForm = useUpdateForm(databaseId);
124
125
126 const [slug, setSlug] = useState(form.slug);
127 const [status, setStatus] = useState<FormStatus>(form.status);
128 const [config, setConfig] = useState<FormConfig>({ ...(form.config ?? {}) });
129 const [draft, setDraft] = useState<DraftQuestion[]>(
130 (form.questions ?? []).map((q) => ({
131 key: keyFor(q),
132 type: q.type,
133 label: q.label,
134 description: q.description,
135 required: q.required,
136 propertyId: q.propertyId ?? null,
137 validation: q.validation ?? null,
138 placeholder: q.placeholder
139 }))
140 );
141 const [editKey, setEditKey] = useState<string | null>(null);
142 const [previewAnswers, setPreviewAnswers] = useState<Record<string, unknown>>({});
143
144 const editing = draft.find((q) => q.key === editKey) ?? null;
145 const busy = updateForm.isPending;
146
147 const unmappedProps = properties.filter(
148 (p) => !draft.some((q) => q.propertyId === p.id) && p.type !== "button"
149 );
150
151 const addQuestion = (propertyId?: string) => {
152 const prop = properties.find((p) => p.id === propertyId);
153 let input: FormQuestionInput = {
154 type: defaultTypeFor(prop?.type),
155 label: prop?.name ?? "New question",
156 required: false,
157 propertyId: propertyId ?? null,
158 placeholder: "",
159 validation: null
160 };
161 if (input.type === "SINGLE_SELECT" || input.type === "MULTI_SELECT") {
162 input = setSelectOptions(input, optionsForProperty(prop));
163 }
164 const q: DraftQuestion = { ...input, key: `dq_${Math.random().toString(36).slice(2, 10)}` };
165 setDraft((d) => [...d, q]);
166 setEditKey(q.key);
167 };
168
169 const removeQuestion = (key: string) => {
170 setDraft((d) => d.filter((q) => q.key !== key));
171 if (editKey === key) setEditKey(null);
172 };
173
174 const move = (key: string, dir: -1 | 1) => {
175 setDraft((d) => {
176 const idx = d.findIndex((q) => q.key === key);
177 const j = idx + dir;
178 if (idx < 0 || j < 0 || j >= d.length) return d;
179 const next = [...d];
180 [next[idx], next[j]] = [next[j], next[idx]];
181 return next;
182 });
183 };
184
185 const saveEdit = (input: FormQuestionInput) => {
186 setDraft((d) => d.map((q) => (q.key === editKey ? { ...q, ...input } : q)));
187 setEditKey(null);
188 };
189
190 const saveForm = () => {
191 const cfg: FormConfig = {
192 ...config,
193 intro: config.intro || undefined,
194 successMessage: config.successMessage || undefined,
195 closedMessage: config.closedMessage || undefined
196 };
197 updateForm.mutate(
198 {
199 formId: form.id,
200 slug,
201 status,
202 config: cfg,
203 questions: draft.map(toInput)
204 },
205 {
206 onSuccess: () => notify({ title: "Form saved" }),
207 onError: (e) => notify({ title: "Save failed", description: (e as Error).message, variant: "destructive" })
208 }
209 );
210 };
211
212 return (
213 <div className="grid gap-4 p-4 lg:grid-cols-2">
214 {}
215 <div className="space-y-4">
216 {}
217 <section className="rounded-lg border border-border p-3">
218 <h3 className="mb-2 text-sm font-medium">Form settings</h3>
219 <div className="space-y-2.5">
220 <div>
221 <label className="mb-1 block text-xs text-muted-foreground">Slug (public URL /f/:slug)</label>
222 <div className="flex items-center gap-1">
223 <span className="text-xs text-muted-foreground">/f/</span>
224 <input className={inputCls} value={slug} onChange={(e) => setSlug(e.target.value)} />
225 </div>
226 </div>
227 <div>
228 <label className="mb-1 block text-xs text-muted-foreground">Status</label>
229 <select className={inputCls} value={status} onChange={(e) => setStatus(e.target.value as FormStatus)}>
230 {FORM_STATUSES.map((s) => (
231 <option key={s} value={s}>
232 {s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())}
233 </option>
234 ))}
235 </select>
236 </div>
237 <div>
238 <label className="mb-1 block text-xs text-muted-foreground">Intro (shown above questions)</label>
239 <textarea className={cn(inputCls, "min-h-14 resize-y")} value={config.intro ?? ""} onChange={(e) => setConfig((c) => ({ ...c, intro: e.target.value }))} />
240 </div>
241 <div>
242 <label className="mb-1 block text-xs text-muted-foreground">Success message</label>
243 <input className={inputCls} value={config.successMessage ?? ""} onChange={(e) => setConfig((c) => ({ ...c, successMessage: e.target.value }))} />
244 </div>
245 <div>
246 <label className="mb-1 block text-xs text-muted-foreground">Closed message</label>
247 <input className={inputCls} value={config.closedMessage ?? ""} onChange={(e) => setConfig((c) => ({ ...c, closedMessage: e.target.value }))} />
248 </div>
249 <button
250 type="button"
251 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
252 onClick={saveForm}
253 disabled={busy || draft.length === 0}
254 >
255 <Save className="size-3.5" aria-hidden /> Save form
256 </button>
257 </div>
258 </section>
259
260 {}
261 <section className="rounded-lg border border-border p-3">
262 <h3 className="mb-2 text-sm font-medium">Questions</h3>
263 <ul className="space-y-1.5">
264 {draft.map((q, i) => (
265 <li key={q.key} className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1.5 text-sm">
266 <GripVertical className="size-4 shrink-0 text-muted-foreground" aria-hidden />
267 <button type="button" className="min-w-0 flex-1 truncate text-left hover:underline" onClick={() => setEditKey(q.key)}>
268 {q.label}
269 {q.required && <span className="ml-0.5 text-destructive">*</span>}
270 </button>
271 <span className="shrink-0 text-[10px] uppercase text-muted-foreground">{q.type}</span>
272 {q.propertyId && <Link2 className="size-3.5 shrink-0 text-primary" aria-label="Mapped to a property" />}
273 <button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30" disabled={i === 0} onClick={() => move(q.key, -1)} aria-label="Move up">
274 <ArrowUp className="size-3.5" />
275 </button>
276 <button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30" disabled={i === draft.length - 1} onClick={() => move(q.key, 1)} aria-label="Move down">
277 <ArrowDown className="size-3.5" />
278 </button>
279 <button type="button" className="rounded p-0.5 text-destructive hover:bg-destructive/10" onClick={() => removeQuestion(q.key)} aria-label="Delete question">
280 <Trash2 className="size-3.5" />
281 </button>
282 </li>
283 ))}
284 {draft.length === 0 && <li className="text-xs text-muted-foreground">No questions yet. Add at least one to save.</li>}
285 </ul>
286
287 <div className="mt-2 flex items-center gap-1.5">
288 <select
289 className="max-w-40 rounded border border-input bg-background px-1.5 py-1 text-xs outline-none"
290 value=""
291 onChange={(e) => {
292 if (e.target.value) addQuestion(e.target.value);
293 }}
294 aria-label="Map new question to a property"
295 >
296 <option value="">From a property…</option>
297 {unmappedProps.map((p) => (
298 <option key={p.id} value={p.id}>
299 {p.name} ({p.type})
300 </option>
301 ))}
302 </select>
303 <button
304 type="button"
305 className="inline-flex items-center gap-1 rounded-md border border-dashed px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
306 onClick={() => addQuestion()}
307 >
308 <Plus className="size-3.5" aria-hidden /> Blank question
309 </button>
310 </div>
311 </section>
312 </div>
313
314 {}
315 <div>
316 <section className="rounded-lg border border-border">
317 <h3 className="border-b border-border px-3 py-2 text-sm font-medium">Live preview</h3>
318 <div className="max-h-[70vh] overflow-y-auto p-4">
319 {config.intro && <p className="mb-4 text-sm text-muted-foreground">{config.intro}</p>}
320 <div className="space-y-4">
321 {draft.map((q) => (
322 <FormField
323 key={q.key}
324 question={{ ...q, id: q.key, position: "" }}
325 value={previewAnswers[q.key]}
326 onChange={(v) => setPreviewAnswers((a) => ({ ...a, [q.key]: v }))}
327 />
328 ))}
329 {draft.length === 0 && <p className="text-sm text-muted-foreground">Add questions to see the form.</p>}
330 </div>
331 </div>
332 </section>
333 </div>
334
335 {}
336 {editing && (
337 <QuestionEditor
338 question={editing}
339 properties={properties}
340 onClose={() => setEditKey(null)}
341 onSave={saveEdit}
342 busy={busy}
343 />
344 )}
345 </div>
346 );
347}
348
349
350
351
352
353function QuestionEditor({
354 question: q,
355 properties,
356 onClose,
357 onSave,
358 busy
359}: {
360 question: DraftQuestion;
361 properties: DatabaseProperty[];
362 onClose: () => void;
363 onSave: (input: FormQuestionInput) => void;
364 busy: boolean;
365}) {
366 const [label, setLabel] = useState(q.label);
367 const [type, setType] = useState<FormQuestionType>(q.type);
368 const [required, setRequired] = useState(q.required);
369 const [placeholder, setPlaceholder] = useState(q.placeholder ?? "");
370 const [propertyId, setPropertyId] = useState(q.propertyId ?? "");
371 const [options, setOptions] = useState<FormOption[]>(questionOptions({ validation: q.validation }));
372
373 const isSelect = type === "SINGLE_SELECT" || type === "MULTI_SELECT";
374
375 return (
376 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-label="Edit question">
377 <div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-lg border bg-background shadow-xl">
378 <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
379 <span className="text-sm font-medium">Edit question</span>
380 <button type="button" className="ml-auto rounded p-1 text-muted-foreground hover:bg-muted" onClick={onClose} aria-label="Close">
381 <X className="size-4" />
382 </button>
383 </div>
384
385 <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
386 <div>
387 <label className="mb-1 block text-xs text-muted-foreground">Label</label>
388 <input className={inputCls} value={label} onChange={(e) => setLabel(e.target.value)} />
389 </div>
390 <div>
391 <label className="mb-1 block text-xs text-muted-foreground">Type</label>
392 <select className={inputCls} value={type} onChange={(e) => setType(e.target.value as FormQuestionType)}>
393 {QUESTION_TYPES.map((t) => (
394 <option key={t.type} value={t.type}>
395 {t.label}
396 </option>
397 ))}
398 </select>
399 </div>
400 <div>
401 <label className="mb-1 block text-xs text-muted-foreground">Map to property (optional)</label>
402 <select className={inputCls} value={propertyId} onChange={(e) => setPropertyId(e.target.value)}>
403 <option value="">Not mapped (stored on the form only)</option>
404 {properties.map((p) => (
405 <option key={p.id} value={p.id}>
406 {p.name} ({p.type})
407 </option>
408 ))}
409 </select>
410 </div>
411 <div>
412 <label className="mb-1 block text-xs text-muted-foreground">Placeholder</label>
413 <input className={inputCls} value={placeholder} onChange={(e) => setPlaceholder(e.target.value)} />
414 </div>
415 {isSelect && (
416 <div>
417 <label className="mb-1 block text-xs text-muted-foreground">Options</label>
418 <div className="space-y-1">
419 {options.map((o, i) => (
420 <div key={o.id ?? i} className="flex items-center gap-1.5">
421 <input
422 className={cn(inputCls, "flex-1")}
423 value={o.name}
424 onChange={(e) => setOptions((list) => list.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
425 />
426 <button
427 type="button"
428 className="rounded p-1 text-muted-foreground hover:bg-muted"
429 onClick={() => setOptions((list) => list.filter((_, j) => j !== i))}
430 aria-label="Remove option"
431 >
432 <X className="size-3.5" />
433 </button>
434 </div>
435 ))}
436 <button
437 type="button"
438 className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
439 onClick={() => setOptions((o) => [...o, { name: "" }])}
440 >
441 <Plus className="size-3" /> Add option
442 </button>
443 </div>
444 </div>
445 )}
446 <label className="flex items-center gap-2 text-sm">
447 <input type="checkbox" className="size-3.5 accent-[oklch(var(--primary))]" checked={required} onChange={(e) => setRequired(e.target.checked)} />
448 Required
449 </label>
450 </div>
451
452 <div className="flex items-center justify-end gap-2 border-t border-border px-4 py-2.5">
453 <button type="button" className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted" onClick={onClose}>
454 Cancel
455 </button>
456 <button
457 type="button"
458 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
459 disabled={busy || !label.trim()}
460 onClick={() => {
461 let input: FormQuestionInput = {
462 type,
463 label: label.trim(),
464 required,
465 placeholder: placeholder || undefined,
466 propertyId: propertyId || null,
467 validation: q.validation ?? null
468 };
469 if (isSelect) input = setSelectOptions(input, options);
470 else if (q.validation) input = { ...input, validation: { ...q.validation, options: undefined } };
471 onSave(input);
472 }}
473 >
474 <Save className="size-3.5" aria-hidden /> Save
475 </button>
476 </div>
477 </div>
478 </div>
479 );
480}
481