"use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; import dynamic from "next/dynamic"; 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, GripVertical, Loader2, Image as ImageIcon, X } from "lucide-react"; import Image from 'next/image'; import { format, parseISO } from "date-fns"; import { DateTimePicker } from "@/components/date-time-picker"; import { getErrorMessage } from "@/lib/utils"; 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, } 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"; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from "@/components/ui/field"; import { getQuestionResponseSettings, getQuestionResponseType, type QuestionResponseType, } from "@/lib/form/question-response-type"; import { SaveStatusIndicator } from "@/components/save-status-indicator"; const RichTextEditor = dynamic( () => import("@/components/rich-text-editor").then( (module) => module.RichTextEditor, ), { ssr: false, loading: () => (
), }, ); function SortableQuestion({ id, position, children, }: { id: string; position: number; children: React.ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); return (
{children}
); } 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; imageAnswerMode: "none" | "optional" | "required"; } interface FormEditorClientProps { form: { id: string; title: string; description: string | null; isOpen: boolean; closesAt: string | null; allowMultipleSubmissions: boolean; }; initialQuestions: Question[]; } function toDateTimeLocalValue(value: string | null) { if (!value) return ""; const date = new Date(value); const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); return localDate.toISOString().slice(0, 16); } 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 [closesAt, setClosesAt] = useState( toDateTimeLocalValue(form.closesAt) ); const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState( form.allowMultipleSubmissions ); 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 closesAtRef = useRef(closesAt); const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions); const questionsRef = useRef(questions); useEffect(() => { formTitleRef.current = formTitle; formDescriptionRef.current = formDescription; formIsOpenRef.current = formIsOpen; closesAtRef.current = closesAt; allowMultipleSubmissionsRef.current = allowMultipleSubmissions; questionsRef.current = questions; }, [allowMultipleSubmissions, closesAt, 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, closesAt: closesAtRef.current ? new Date(closesAtRef.current).toISOString() : null, allowMultipleSubmissions: allowMultipleSubmissionsRef.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, imageAnswerMode: q.imageAnswerMode, })); 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 handleClosesAtChange = (val: string) => { closesAtRef.current = val; setClosesAt(val); formAutoSave.schedule(); }; const selectedCloseDateTime = closesAt ? parseISO(closesAt) : undefined; const handleAllowMultipleSubmissionsChange = (val: boolean) => { allowMultipleSubmissionsRef.current = val; setAllowMultipleSubmissions(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 handleResponseTypeChange = ( question: Question, responseType: QuestionResponseType, ) => { handleUpdateQuestion( question.id, getQuestionResponseSettings(responseType, question.required), ); }; 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 remove 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.
Form Title handleFormTitleChange(e.target.value)} placeholder="e.g. Untitled Form" aria-invalid={!formTitle.trim()} /> {!formTitle.trim() && (

Form title is required.

)}
Description handleFormDescriptionChange(html)} placeholder="Provide a description..." className="min-h-20 w-full" /> Accepting Submissions
Automatically close submissions Date and time handleClosesAtChange( date ? format(date, "yyyy-MM-dd'T'HH:mm") : "" ) } /> Changes are saved automatically. Leave blank for no deadline. The selected date and time use your device's time zone.
Allow multiple submissions Users can create separate responses instead of only editing their current response.

Questions

question.id)} strategy={verticalListSortingStrategy} >
{questions.map((q, index) => (
{stripHtml(q.label) || `Untitled question ${index + 1}`} Question {index + 1} · {questionTypeLabel(q.type)} {q.required || q.imageAnswerMode === "required" ? " · Required" : ""}
Remove question {index + 1}? This permanently removes the question and its stored answers. Keep question void handleDeleteQuestion(q.id)} > Remove question
handleUpdateQuestion(q.id, { label: html })} placeholder="Question Text" className="w-full" />
{q.imageUrl ? (
{q.label}
) : (
handleImageUpload(q.id, e)} className="hidden" disabled={uploadingId !== null} />
)} Response type “Image only” hides the normal answer field. “Answer + required image” requires both. {(q.imageAnswerMode === "none" || q.imageAnswerMode === "optional") && ( handleUpdateQuestion(q.id, { required: checked }) } />
Require answer Users must complete the normal answer field.
)}
{(q.type === "radio" || q.type === "checkbox") && (
{(q.options || []).map((opt, optIdx) => (