feat: implement debounced auto-save for form settings and questions in the admin editor

This commit is contained in:
2026-06-20 19:41:14 +07:00 Unverified
parent cb288588d8
commit 1b56937109
3 changed files with 208 additions and 57 deletions
+199 -57
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useTransition } from "react";
import React, { useState, useTransition, 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";
@@ -10,6 +10,8 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import {
@@ -19,9 +21,10 @@ import {
reorderQuestions,
bulkUpdateQuestions,
} from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X, Check } from "lucide-react";
import Image from 'next/image';
import { RichTextEditor } from "@/components/rich-text-editor";
import { FormattedText } from "@/components/formatted-text";
interface Question {
id: string;
@@ -45,6 +48,8 @@ interface FormEditorClientProps {
initialQuestions: Question[];
}
type AutoSaveStatus = "idle" | "saving" | "saved";
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
const router = useRouter();
const [formTitle, setFormTitle] = useState(form.title);
@@ -52,28 +57,92 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null);
const [isFormSaving, startFormSaveTransition] = useTransition();
const [isQuestionsSaving, startQuestionsSaveTransition] = useTransition();
const saveFormMetadata = () => {
if (!formTitle.trim()) {
toast.error("Form title is required.");
return;
}
const [formSaveStatus, setFormSaveStatus] = useState<AutoSaveStatus>("idle");
const [questionsSaveStatus, setQuestionsSaveStatus] = useState<AutoSaveStatus>("idle");
startFormSaveTransition(async () => {
const formTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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);
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
questionsRef.current = questions;
const autoSaveForm = useCallback(() => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
setFormSaveStatus("idle");
formTimerRef.current = setTimeout(async () => {
const title = formTitleRef.current;
if (!title.trim()) return;
setFormSaveStatus("saving");
try {
await updateForm(form.id, {
title: formTitle,
description: formDescription,
isOpen: formIsOpen,
title,
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
});
toast.success("Form metadata saved!");
setFormSaveStatus("saved");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to update form.");
setTimeout(() => setFormSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save form settings.");
setFormSaveStatus("idle");
}
});
}, 1000);
}, [form.id, router]);
const autoSaveQuestions = useCallback(() => {
if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current);
setQuestionsSaveStatus("idle");
questionsTimerRef.current = setTimeout(async () => {
const current = questionsRef.current;
if (current.length === 0) return;
setQuestionsSaveStatus("saving");
try {
const formattedUpdates = current.map(q => ({
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
setQuestionsSaveStatus("saved");
router.refresh();
setTimeout(() => setQuestionsSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save questions.");
setQuestionsSaveStatus("idle");
}
}, 1500);
}, [form.id, router]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current);
};
}, []);
const handleFormTitleChange = (val: string) => {
setFormTitle(val);
autoSaveForm();
};
const handleFormDescriptionChange = (val: string) => {
setFormDescription(val);
autoSaveForm();
};
const handleFormIsOpenChange = (val: boolean) => {
setFormIsOpen(val);
autoSaveForm();
};
const handleAddQuestion = async () => {
@@ -96,23 +165,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
setQuestions((prev) =>
prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q))
);
};
const saveQuestions = () => {
startQuestionsSaveTransition(async () => {
try {
const formattedUpdates = questions.map(q => ({
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
toast.success("Questions saved!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to save questions.");
}
});
autoSaveQuestions();
};
const handleDeleteQuestion = async (id: string) => {
@@ -139,6 +192,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
});
setQuestions(newQuestions);
autoSaveQuestions();
};
const handleImageUpload = async (questionId: string, e: React.ChangeEvent<HTMLInputElement>) => {
@@ -223,8 +277,13 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>Configure the main title and description of your form.</CardDescription>
<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={formSaveStatus} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -232,7 +291,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<Input
id="form-title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
onChange={(e) => handleFormTitleChange(e.target.value)}
placeholder="e.g. Untitled Form"
/>
</div>
@@ -241,7 +300,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<Textarea
id="form-description"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
onChange={(e) => handleFormDescriptionChange(e.target.value)}
placeholder="Provide a description..."
className="min-h-20"
/>
@@ -250,35 +309,26 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<Switch
id="form-is-open"
checked={formIsOpen}
onCheckedChange={setFormIsOpen}
onCheckedChange={handleFormIsOpenChange}
/>
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
Accepting Submissions
</Label>
</div>
<div className="flex justify-end pt-2">
<Button onClick={saveFormMetadata} disabled={isFormSaving} className="gap-2">
{isFormSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Settings
</Button>
</div>
</CardContent>
</Card>
<Separator />
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Questions</h2>
<div className="flex items-center gap-2">
<Button onClick={saveQuestions} disabled={isQuestionsSaving} className="gap-2">
{isQuestionsSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Questions
</Button>
<Button onClick={handleAddQuestion} variant="outline" className="gap-2">
<Plus className="w-4 h-4" />
Add Question
</Button>
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold">Questions</h2>
<SaveStatusIndicator status={questionsSaveStatus} />
</div>
<Button onClick={handleAddQuestion} variant="outline" className="gap-2">
<Plus className="w-4 h-4" />
Add Question
</Button>
</div>
<div className="space-y-4">
@@ -305,8 +355,8 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<SelectContent>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="textarea">Paragraph</SelectItem>
<SelectItem value="radio">Multiple Choice</SelectItem>
<SelectItem value="checkbox">Checkboxes</SelectItem>
<SelectItem value="radio">Choose one</SelectItem>
<SelectItem value="checkbox">Multiple choose</SelectItem>
</SelectContent>
</Select>
@@ -413,6 +463,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
</div>
)}
{/* Preview answer field */}
<AnswerPreview question={q} />
<div className="flex justify-between items-center pt-2 border-t border-border">
<div className="flex items-center flex-wrap gap-6">
<div className="flex items-center gap-2">
@@ -454,3 +507,92 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
</div>
);
}
function SaveStatusIndicator({ status }: { status: AutoSaveStatus }) {
if (status === "idle") return null;
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "saving" && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</>
)}
{status === "saved" && (
<>
<Check className="w-3 h-3 text-green-500" />
Saved
</>
)}
</span>
);
}
function AnswerPreview({ question }: { question: Question }) {
const q = question;
return (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-3 bg-muted/30">
<Label className="text-xs text-muted-foreground mb-2 block">Answer Preview</Label>
{q.type === "text" && (
<Input
placeholder="Short answer text"
disabled
className="max-w-sm"
/>
)}
{q.type === "textarea" && (
<Textarea
placeholder="Long answer text"
disabled
className="max-w-sm min-h-20 resize-none"
/>
)}
{q.type === "radio" && (
<RadioGroup className="space-y-2">
{(q.options || []).map((opt, idx) => (
<div key={idx} className="flex items-center space-x-2">
<RadioGroupItem value={opt} id={`preview-${q.id}-${idx}`} />
<Label htmlFor={`preview-${q.id}-${idx}`} className="font-normal text-sm cursor-pointer">
<FormattedText text={opt} />
</Label>
</div>
))}
{q.allowOther && (
<div className="flex items-center space-x-2">
<RadioGroupItem value="__OTHER__" id={`preview-${q.id}-other`} />
<Label htmlFor={`preview-${q.id}-other`} className="font-normal text-sm text-muted-foreground cursor-pointer">
Other...
</Label>
</div>
)}
</RadioGroup>
)}
{q.type === "checkbox" && (
<div className="space-y-2">
{(q.options || []).map((opt, idx) => (
<div key={idx} className="flex items-center space-x-2">
<Checkbox id={`preview-${q.id}-${idx}`} />
<Label htmlFor={`preview-${q.id}-${idx}`} className="font-normal text-sm cursor-pointer">
<FormattedText text={opt} />
</Label>
</div>
))}
{q.allowOther && (
<div className="flex items-center space-x-2">
<Checkbox id={`preview-${q.id}-other`} />
<Label htmlFor={`preview-${q.id}-other`} className="font-normal text-sm text-muted-foreground cursor-pointer">
Other...
</Label>
</div>
)}
</div>
)}
</div>
);
}
+8
View File
@@ -2,6 +2,14 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { FormLayoutClient } from "./client";
import LoginDialog from "@/components/login-dialog";
import { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Erika! - กรอกแบบฟอร์ม",
template: "%s | Erika!",
},
}
export default async function FormLayout({
children,
+1
View File
@@ -7,4 +7,5 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL!,
},
schemaFilter: ["public", "account"],
});