feat: implement debounced auto-save for form settings and questions in the admin editor
This commit is contained in:
+199
-57
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,4 +7,5 @@ export default defineConfig({
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
schemaFilter: ["public", "account"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user