From 1b569371092f7361e86f0f57b81e7fdbf918d051 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 20 Jun 2026 19:41:14 +0700 Subject: [PATCH] feat: implement debounced auto-save for form settings and questions in the admin editor --- app/form/admin/client.tsx | 256 +++++++++++++++++++++++++++++--------- app/form/layout.tsx | 8 ++ drizzle.config.ts | 1 + 3 files changed, 208 insertions(+), 57 deletions(-) diff --git a/app/form/admin/client.tsx b/app/form/admin/client.tsx index 41c32cb..2c9ad20 100644 --- a/app/form/admin/client.tsx +++ b/app/form/admin/client.tsx @@ -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(initialQuestions); const [uploadingId, setUploadingId] = useState(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("idle"); + const [questionsSaveStatus, setQuestionsSaveStatus] = useState("idle"); - startFormSaveTransition(async () => { + const formTimerRef = useRef | null>(null); + const questionsTimerRef = useRef | 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) => { @@ -223,8 +277,13 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
- Form Settings - Configure the main title and description of your form. +
+
+ Form Settings + Configure the main title and description of your form. +
+ +
@@ -232,7 +291,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC setFormTitle(e.target.value)} + onChange={(e) => handleFormTitleChange(e.target.value)} placeholder="e.g. Untitled Form" />
@@ -241,7 +300,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC