Files
erika/app/admin/form/[id]/client.tsx
T

732 lines
25 KiB
TypeScript

"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<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(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<Question>) => {
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 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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingId(questionId);
toast.promise(
new Promise<string>(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 (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Form Settings</CardTitle>
<CardDescription>Configure the main title and description of your form.</CardDescription>
</div>
<SaveStatusIndicator
status={formAutoSave.status}
error={formAutoSave.error}
onRetry={formAutoSave.retry}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="form-title">Form Title</Label>
<Input
id="form-title"
value={formTitle}
onChange={(e) => handleFormTitleChange(e.target.value)}
placeholder="e.g. Untitled Form"
aria-invalid={!formTitle.trim()}
/>
{!formTitle.trim() && (
<p className="text-sm text-destructive">Form title is required.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="form-description">Description</Label>
<RichTextEditor
value={formDescription}
onChange={(html) => handleFormDescriptionChange(html)}
placeholder="Provide a description..."
className="min-h-20 w-full"
/>
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch
id="form-is-open"
checked={formIsOpen}
onCheckedChange={handleFormIsOpenChange}
/>
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
Accepting Submissions
</Label>
</div>
</CardContent>
</Card>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold">Questions</h2>
<SaveStatusIndicator
status={questionsAutoSave.status}
error={questionsAutoSave.error}
onRetry={questionsAutoSave.retry}
/>
</div>
<Button onClick={handleAddQuestion} variant="outline" disabled={addingQuestion}>
{addingQuestion ? (
<Loader2 data-icon="inline-start" className="animate-spin" />
) : (
<Plus data-icon="inline-start" />
)}
{addingQuestion ? "Adding" : "Add question"}
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={questions.map((question) => question.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{questions.map((q, index) => (
<SortableQuestion key={q.id} id={q.id} position={index + 1}>
<Accordion type="single" collapsible defaultValue={index === 0 ? q.id : undefined}>
<AccordionItem value={q.id} className="border-0">
<Card>
<CardHeader className="pl-12">
<div className="relative flex items-center">
<div className="min-w-0 flex-1">
<AccordionTrigger className="w-full min-w-0 py-0 pr-32 hover:no-underline [&_[data-slot=accordion-trigger-icon]]:absolute [&_[data-slot=accordion-trigger-icon]]:right-0 [&_[data-slot=accordion-trigger-icon]]:top-1/2 [&_[data-slot=accordion-trigger-icon]]:-translate-y-1/2">
<div className="min-w-0 text-left">
<CardTitle className="truncate text-base">
{stripHtml(q.label) || `Untitled question ${index + 1}`}
</CardTitle>
<CardDescription>
Question {index + 1} · {questionTypeLabel(q.type)}
{q.required ? " · Required" : ""}
</CardDescription>
</div>
</AccordionTrigger>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-8 top-1/2 -translate-y-1/2 text-destructive"
aria-label={`Remove question ${index + 1}`}
>
<Trash data-icon="inline-start" />
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove question {index + 1}?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the question and its stored answers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep question</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => void handleDeleteQuestion(q.id)}
>
Remove question
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<AccordionContent className="h-auto">
<CardContent className="flex flex-col gap-4 pt-0">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 w-full">
<RichTextEditor
value={q.label}
onChange={(html) => handleUpdateQuestion(q.id, { label: html })}
placeholder="Question Text"
className="w-full"
/>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => handleMove(index, "up")}
aria-label={`Move question ${index + 1} up`}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === questions.length - 1}
onClick={() => handleMove(index, "down")}
aria-label={`Move question ${index + 1} down`}
>
<ArrowDown />
</Button>
</div>
<Select
value={q.type}
onValueChange={(val) => handleUpdateQuestion(q.id, { type: val as Question["type"] })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="text">Short answer</SelectItem>
<SelectItem value="textarea">Paragraph</SelectItem>
<SelectItem value="radio">Choose one</SelectItem>
<SelectItem value="checkbox">Choose multiple</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{q.imageUrl ? (
<div className="relative max-w-full h-auto inline-block shrink-0 group">
<Image
width={200}
height={200}
src={q.imageUrl}
alt={q.label}
unoptimized
className="w-32 h-auto rounded-md border object-contain"
/>
<Button
variant="destructive"
size="icon"
className="absolute -right-2 -top-2 size-6 rounded-full opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
onClick={() => handleRemoveImage(q.id)}
aria-label={`Remove image from question ${index + 1}`}
>
<X />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Label
htmlFor={`img-upload-${q.id}`}
className="flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-accent text-sm text-muted-foreground"
>
{uploadingId === q.id ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<ImageIcon className="w-4 h-4 text-primary" />
)}
<span>Add Image</span>
</Label>
<input
id={`img-upload-${q.id}`}
type="file"
accept="image/*"
onChange={(e) => handleImageUpload(q.id, e)}
className="hidden"
disabled={uploadingId !== null}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id={`required-${q.id}`}
checked={q.required}
onCheckedChange={(checked) =>
handleUpdateQuestion(q.id, { required: checked })
}
/>
<Label
htmlFor={`required-${q.id}`}
className="text-sm font-normal text-muted-foreground"
>
Required
</Label>
</div>
</div>
{(q.type === "radio" || q.type === "checkbox") && (
<div className="space-y-2 pl-4 border-l-2 border-primary/20">
<Label className="text-sm font-semibold">Options</Label>
<div className="space-y-2">
{(q.options || []).map((opt, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
<Textarea
value={opt}
onChange={(e) => handleUpdateOption(q, optIdx, e.target.value)}
className="min-h-10 max-w-sm py-2"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveOption(q, optIdx)}
aria-label={`Remove option ${optIdx + 1}`}
>
<Trash />
</Button>
</div>
))}
<Button
variant="ghost"
size="sm"
onClick={() => handleAddOption(q)}
className="text-primary hover:text-primary/80 h-8 gap-1 pl-1"
>
<Plus className="w-4 h-4" />
Add Option
</Button>
</div>
</div>
)}
{(q.type === "radio" || q.type === "checkbox") && (
<div className="flex items-center gap-2 border-t border-border pt-2">
<Switch
id={`allowOther-${q.id}`}
checked={q.allowOther}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { allowOther: checked })}
/>
<Label htmlFor={`allowOther-${q.id}`} className="text-sm font-normal text-muted-foreground">
Add &apos;Other&apos; option
</Label>
</div>
)}
</CardContent>
</AccordionContent>
</Card>
</AccordionItem>
</Accordion>
</SortableQuestion>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save."}</span>
<Button
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function questionTypeLabel(type: Question["type"]) {
return {
text: "Short answer",
textarea: "Paragraph",
radio: "Choose one",
checkbox: "Choose multiple",
}[type];
}
function SortableQuestion({
id,
position,
children,
}: {
id: string;
position: number;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
className="relative"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-4"
aria-label={`Drag question ${position} to reorder`}
{...attributes}
{...listeners}
>
<GripVertical />
</Button>
{children}
</div>
);
}