"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
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 ? (
) : (
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) => (
))}
)}
{(q.type === "radio" || q.type === "checkbox") && (
handleUpdateQuestion(q.id, { allowOther: checked })}
/>
)}
))}
);
}
function questionTypeLabel(type: Question["type"]) {
return {
text: "Short answer",
textarea: "Paragraph",
radio: "Choose one",
checkbox: "Choose multiple",
}[type];
}