"use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { toast } from "sonner"; import { updateForm } from "@/app/admin/form/actions"; import { createQuestion, updateQuestion, deleteQuestion, bulkUpdateQuestions, } from "@/app/actions/questions"; import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react"; import Image from 'next/image'; import { RichTextEditor } from "@/components/rich-text-editor"; import { getErrorMessage } from "@/lib/errors"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useDebouncedAutoSave, type AutoSaveStatus, } from "@/hooks/use-debounced-autosave"; import { stripHtml } from "@/lib/utils"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; interface Question { id: string; formId: string; type: "text" | "textarea" | "radio" | "checkbox"; label: string; imageUrl: string | null; displayOrder: number; required: boolean; options: string[] | null; allowOther: boolean; } interface FormEditorClientProps { form: { id: string; title: string; description: string | null; isOpen: boolean; }; initialQuestions: Question[]; } export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) { const router = useRouter(); const [formTitle, setFormTitle] = useState(form.title); const [formDescription, setFormDescription] = useState(form.description || ""); const [formIsOpen, setFormIsOpen] = useState(form.isOpen); const [questions, setQuestions] = useState(initialQuestions); const [uploadingId, setUploadingId] = useState(null); const [addingQuestion, setAddingQuestion] = useState(false); // Refs to always read latest state inside debounced callbacks const formTitleRef = useRef(formTitle); const formDescriptionRef = useRef(formDescription); const formIsOpenRef = useRef(formIsOpen); const questionsRef = useRef(questions); useEffect(() => { formTitleRef.current = formTitle; formDescriptionRef.current = formDescription; formIsOpenRef.current = formIsOpen; questionsRef.current = questions; }, [formDescription, formIsOpen, formTitle, questions]); const saveForm = useCallback(async () => { const title = formTitleRef.current; if (!title.trim()) { throw new Error("Form title is required."); } await updateForm(form.id, { title: title.trim(), description: formDescriptionRef.current, isOpen: formIsOpenRef.current, }); router.refresh(); }, [form.id, router]); const saveQuestions = useCallback(async () => { const current = questionsRef.current; const formattedUpdates = current.map(q => ({ ...q, options: q.options === null ? [] : q.options, allowOther: q.allowOther, })); await bulkUpdateQuestions(form.id, formattedUpdates); router.refresh(); }, [form.id, router]); const formAutoSave = useDebouncedAutoSave({ save: saveForm, delay: 1000 }); const questionsAutoSave = useDebouncedAutoSave({ save: saveQuestions, delay: 1200, }); const handleFormTitleChange = (val: string) => { formTitleRef.current = val; setFormTitle(val); formAutoSave.schedule(); }; const handleFormDescriptionChange = (val: string) => { formDescriptionRef.current = val; setFormDescription(val); formAutoSave.schedule(); }; const handleFormIsOpenChange = (val: boolean) => { formIsOpenRef.current = val; setFormIsOpen(val); formAutoSave.schedule(); }; const handleAddQuestion = async () => { setAddingQuestion(true); try { const displayOrder = questions.length; const question = await createQuestion(form.id, { type: "text", label: `Untitled Question ${questions.length + 1}`, required: false, displayOrder, options: [], }); setQuestions((current) => { const next = [...current, question]; questionsRef.current = next; return next; }); router.refresh(); } catch (err: unknown) { toast.error(getErrorMessage(err, "Could not add the question.")); } finally { setAddingQuestion(false); } }; const handleUpdateQuestion = (id: string, updates: Partial) => { setQuestions((prev) => { const next = prev.map((q) => q.id === id ? { ...q, ...updates } as Question : q ); questionsRef.current = next; return next; } ); questionsAutoSave.schedule(); }; const handleDeleteQuestion = async (id: string) => { try { await deleteQuestion(id, form.id); setQuestions((current) => { const next = current.filter((question) => question.id !== id); questionsRef.current = next; return next; }); router.refresh(); } catch (err: unknown) { toast.error(getErrorMessage(err, "Could not delete the question.")); } }; const handleMove = (index: number, direction: "up" | "down") => { const newQuestions = [...questions]; const targetIndex = direction === "up" ? index - 1 : index + 1; if (targetIndex < 0 || targetIndex >= newQuestions.length) return; const temp = newQuestions[index]; newQuestions[index] = newQuestions[targetIndex]; newQuestions[targetIndex] = temp; // Update display orders based on new positions newQuestions.forEach((q, idx) => { q.displayOrder = idx; }); questionsRef.current = newQuestions; setQuestions(newQuestions); questionsAutoSave.schedule(); }; const handleImageUpload = async (questionId: string, e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); setUploadingId(questionId); toast.promise( new Promise(async (resolve, reject) => { try { const res = await fetch("/api/upload", { method: "POST", body: formData, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to upload image"); resolve(data.url); } catch (error) { reject(error); } }).then(async (imageUrl) => { await updateQuestion(questionId, form.id, { imageUrl }); setQuestions((prev) => prev.map((q) => (q.id === questionId ? { ...q, imageUrl } : q)) ); router.refresh(); }).finally(() => { setUploadingId(null); }), { loading: "Uploading image...", success: "Image uploaded!", error: (err: unknown) => getErrorMessage(err, "Failed to upload image."), } ); }; const handleRemoveImage = async (questionId: string) => { toast.promise( updateQuestion(questionId, form.id, { imageUrl: null }).then(() => { setQuestions((prev) => prev.map((q) => (q.id === questionId ? { ...q, imageUrl: null } : q)) ); router.refresh(); }), { loading: "Removing image...", success: "Image removed.", error: (err: unknown) => getErrorMessage(err, "Failed to remove image."), } ); }; const handleAddOption = (question: Question) => { const options = question.options ? [...question.options] : []; options.push(`Option ${options.length + 1}`); handleUpdateQuestion(question.id, { options }); }; const handleUpdateOption = (question: Question, optIndex: number, val: string) => { if (!question.options) return; const options = [...question.options]; options[optIndex] = val; handleUpdateQuestion(question.id, { options }); }; const handleRemoveOption = (question: Question, optIndex: number) => { if (!question.options) return; const options = question.options.filter((_, idx) => idx !== optIndex); handleUpdateQuestion(question.id, { options }); }; const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; const oldIndex = questions.findIndex((question) => question.id === active.id); const newIndex = questions.findIndex((question) => question.id === over.id); const next = arrayMove(questions, oldIndex, newIndex).map((question, index) => ({ ...question, displayOrder: index, })); questionsRef.current = next; setQuestions(next); questionsAutoSave.schedule(); }; return (
Form Settings Configure the main title and description of your form.
handleFormTitleChange(e.target.value)} placeholder="e.g. Untitled Form" aria-invalid={!formTitle.trim()} /> {!formTitle.trim() && (

Form title is required.

)}
handleFormDescriptionChange(html)} placeholder="Provide a description..." className="min-h-20 w-full" />

Questions

question.id)} strategy={verticalListSortingStrategy} >
{questions.map((q, index) => (
{stripHtml(q.label) || `Untitled question ${index + 1}`} Question {index + 1} · {questionTypeLabel(q.type)} {q.required ? " · Required" : ""}
handleUpdateQuestion(q.id, { label: html })} placeholder="Question Text" className="w-full" />
{q.imageUrl ? (
{q.label}
) : (
handleImageUpload(q.id, e)} className="hidden" disabled={uploadingId !== null} />
)}
{(q.type === "radio" || q.type === "checkbox") && (
{(q.options || []).map((opt, optIdx) => (