From bc856402e9314251ed77e49378ac82ea3d15335f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 27 Jul 2026 19:11:27 +0700 Subject: [PATCH] Improve admin form workspace UX --- .gitignore | 3 +- app/actions/questions.ts | 22 +- app/admin/form/[id]/client.tsx | 614 +++++++++--------- app/admin/form/[id]/extra/client.tsx | 301 ++++++--- app/admin/form/[id]/extra/page.tsx | 11 +- app/admin/form/[id]/layout.tsx | 25 +- app/admin/form/[id]/loading.tsx | 27 + app/admin/form/[id]/page.tsx | 11 +- app/admin/form/[id]/preview/page.tsx | 39 ++ .../[id]/result/[submissionId]/client.tsx | 54 +- .../form/[id]/result/[submissionId]/page.tsx | 55 +- app/admin/form/[id]/result/export/route.ts | 104 +++ app/admin/form/[id]/result/page.tsx | 251 ++++--- app/admin/form/actions.ts | 31 + app/admin/form/page.tsx | 64 +- app/form/client.tsx | 20 +- bun.lock | 19 + components/delete-buttons.tsx | 38 +- components/form-admin-sidebar.tsx | 163 +++++ components/guarded-link.tsx | 39 ++ components/results-filters.tsx | 194 ++++++ components/rich-text-editor.tsx | 17 + components/ui/calendar.tsx | 222 +++++++ components/ui/popover.tsx | 89 +++ db/schema/form.ts | 6 + drizzle/0012_clammy_the_stranger.sql | 1 + drizzle/meta/_journal.json | 7 + hooks/use-debounced-autosave.ts | 104 +++ lib/admin-form-results.ts | 133 ++++ lib/admin-form-save-bridge.ts | 16 + lib/admin-form-ux.test.ts | 68 ++ lib/discord-webhook-settings.ts | 37 ++ package.json | 5 + 33 files changed, 2194 insertions(+), 596 deletions(-) create mode 100644 app/admin/form/[id]/loading.tsx create mode 100644 app/admin/form/[id]/preview/page.tsx create mode 100644 app/admin/form/[id]/result/export/route.ts create mode 100644 components/form-admin-sidebar.tsx create mode 100644 components/guarded-link.tsx create mode 100644 components/results-filters.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/popover.tsx create mode 100644 drizzle/0012_clammy_the_stranger.sql create mode 100644 hooks/use-debounced-autosave.ts create mode 100644 lib/admin-form-results.ts create mode 100644 lib/admin-form-save-bridge.ts create mode 100644 lib/admin-form-ux.test.ts create mode 100644 lib/discord-webhook-settings.ts diff --git a/.gitignore b/.gitignore index 34573de..ed2a7fe 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ yarn-error.log* next-env.d.ts public/form -drizzle \ No newline at end of file +drizzle/* +!drizzle/0012_clammy_the_stranger.sql diff --git a/app/actions/questions.ts b/app/actions/questions.ts index f4ce4f5..094eed5 100644 --- a/app/actions/questions.ts +++ b/app/actions/questions.ts @@ -22,15 +22,18 @@ export async function createQuestion( } ) { await requireAdmin(); - await db.insert(questions).values({ - formId, - type: data.type, - label: sanitizeHtml(data.label), - required: data.required, - displayOrder: data.displayOrder, - options: data.options ?? [], - allowOther: false, - }); + const [question] = await db + .insert(questions) + .values({ + formId, + type: data.type, + label: sanitizeHtml(data.label), + required: data.required, + displayOrder: data.displayOrder, + options: data.options ?? [], + allowOther: false, + }) + .returning(); void sse.forms.pub("update", { formId, entity: "question", @@ -38,6 +41,7 @@ export async function createQuestion( }); revalidatePath("/form"); revalidatePath("/admin/form"); + return question; } export async function updateQuestion( diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index f7d248e..943b9cd 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -1,18 +1,15 @@ "use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; -import { useRouter, usePathname } from "next/navigation"; -import Link from "next/link"; +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, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Select, SelectContent, SelectGroup, 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/admin/form/actions"; import { @@ -21,25 +18,49 @@ import { deleteQuestion, bulkUpdateQuestions, } from "@/app/actions/questions"; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarHeader, - SidebarFooter, -} from "@/components/ui/sidebar"; -import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, LayoutDashboard, ClipboardList, ArrowLeft, Webhook } from "lucide-react"; +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 { FormattedText } from "@/components/formatted-text"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar"; 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; @@ -63,8 +84,6 @@ 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); @@ -72,20 +91,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const [formIsOpen, setFormIsOpen] = useState(form.isOpen); const [questions, setQuestions] = useState(initialQuestions); const [uploadingId, setUploadingId] = useState(null); - - const [formSaveStatus, setFormSaveStatus] = useState("idle"); - const [questionsSaveStatus, setQuestionsSaveStatus] = useState("idle"); - - const formTimerRef = useRef | null>(null); - const questionsTimerRef = useRef | null>(null); - const formDirtyRef = useRef(false); - const questionsDirtyRef = useRef(false); - const formSavingRef = useRef(false); - const questionsSavingRef = useRef(false); - const formRevisionRef = useRef(0); - const questionsRevisionRef = useRef(0); - const activeFormSaveRef = useRef(0); - const activeQuestionsSaveRef = useRef(0); + const [addingQuestion, setAddingQuestion] = useState(false); // Refs to always read latest state inside debounced callbacks const formTitleRef = useRef(formTitle); @@ -100,125 +106,89 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC questionsRef.current = questions; }, [formDescription, formIsOpen, formTitle, questions]); - const autoSaveForm = useCallback(() => { - if (formTimerRef.current) clearTimeout(formTimerRef.current); - setFormSaveStatus("idle"); - formTimerRef.current = setTimeout(async () => { + const saveForm = useCallback(async () => { const title = formTitleRef.current; - if (!title.trim()) return; - const revision = formRevisionRef.current; - activeFormSaveRef.current = revision; - formSavingRef.current = true; - setFormSaveStatus("saving"); - try { - await updateForm(form.id, { - title, - description: formDescriptionRef.current, - isOpen: formIsOpenRef.current, - }); - if (formRevisionRef.current === revision) { - formDirtyRef.current = false; - } - setFormSaveStatus("saved"); - router.refresh(); - setTimeout(() => setFormSaveStatus("idle"), 2000); - } catch { - toast.error("Failed to auto-save form settings."); - setFormSaveStatus("idle"); - } finally { - if (activeFormSaveRef.current === revision) { - formSavingRef.current = false; - } + if (!title.trim()) { + throw new Error("Form title is required."); } - }, 1000); + await updateForm(form.id, { + title: title.trim(), + description: formDescriptionRef.current, + isOpen: formIsOpenRef.current, + }); + router.refresh(); }, [form.id, router]); - const autoSaveQuestions = useCallback(() => { - if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current); - setQuestionsSaveStatus("idle"); - questionsTimerRef.current = setTimeout(async () => { + const saveQuestions = useCallback(async () => { const current = questionsRef.current; - if (current.length === 0) return; - const revision = questionsRevisionRef.current; - activeQuestionsSaveRef.current = revision; - questionsSavingRef.current = true; - setQuestionsSaveStatus("saving"); - try { - const formattedUpdates = current.map(q => ({ - ...q, - options: q.options === null ? [] : q.options, - allowOther: q.allowOther, - })); - await bulkUpdateQuestions(form.id, formattedUpdates); - if (questionsRevisionRef.current === revision) { - questionsDirtyRef.current = false; - } - setQuestionsSaveStatus("saved"); - router.refresh(); - setTimeout(() => setQuestionsSaveStatus("idle"), 2000); - } catch { - toast.error("Failed to auto-save questions."); - setQuestionsSaveStatus("idle"); - } finally { - if (activeQuestionsSaveRef.current === revision) { - questionsSavingRef.current = false; - } - } - }, 1500); + 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]); - // Cleanup timers on unmount - useEffect(() => { - return () => { - if (formTimerRef.current) clearTimeout(formTimerRef.current); - if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current); - }; - }, []); + const formAutoSave = useDebouncedAutoSave({ save: saveForm, delay: 1000 }); + const questionsAutoSave = useDebouncedAutoSave({ + save: saveQuestions, + delay: 1200, + }); const handleFormTitleChange = (val: string) => { - formDirtyRef.current = true; - formRevisionRef.current += 1; + formTitleRef.current = val; setFormTitle(val); - autoSaveForm(); + formAutoSave.schedule(); }; const handleFormDescriptionChange = (val: string) => { - formDirtyRef.current = true; - formRevisionRef.current += 1; + formDescriptionRef.current = val; setFormDescription(val); - autoSaveForm(); + formAutoSave.schedule(); }; const handleFormIsOpenChange = (val: boolean) => { - formDirtyRef.current = true; - formRevisionRef.current += 1; + formIsOpenRef.current = val; setFormIsOpen(val); - autoSaveForm(); + formAutoSave.schedule(); }; const handleAddQuestion = async () => { + setAddingQuestion(true); try { const displayOrder = questions.length; - await createQuestion(form.id, { + 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) { - console.error("Failed to add question:", err); + toast.error(getErrorMessage(err, "Could not add the question.")); + } finally { + setAddingQuestion(false); } }; const handleUpdateQuestion = (id: string, updates: Partial) => { - questionsDirtyRef.current = true; - questionsRevisionRef.current += 1; setQuestions((prev) => - prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q)) + { + const next = prev.map((q) => + q.id === id ? { ...q, ...updates } as Question : q + ); + questionsRef.current = next; + return next; + } ); - autoSaveQuestions(); + questionsAutoSave.schedule(); }; const handleDeleteQuestion = async (id: string) => { @@ -231,7 +201,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC }); router.refresh(); } catch (err: unknown) { - console.error("Failed to delete question:", err); + toast.error(getErrorMessage(err, "Could not delete the question.")); } }; @@ -249,10 +219,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC q.displayOrder = idx; }); - questionsDirtyRef.current = true; - questionsRevisionRef.current += 1; + questionsRef.current = newQuestions; setQuestions(newQuestions); - autoSaveQuestions(); + questionsAutoSave.schedule(); }; const handleImageUpload = async (questionId: string, e: React.ChangeEvent) => { @@ -329,26 +298,28 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC handleUpdateQuestion(question.id, { options }); }; - useEffect(() => { - if (formDirtyRef.current || formSavingRef.current) return; + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); - setFormTitle(form.title); - setFormDescription(form.description || ""); - setFormIsOpen(form.isOpen); - formTitleRef.current = form.title; - formDescriptionRef.current = form.description || ""; - formIsOpenRef.current = form.isOpen; - }, [form]); - - useEffect(() => { - if (questionsDirtyRef.current || questionsSavingRef.current) return; - - setQuestions(initialQuestions); - questionsRef.current = initialQuestions; - }, [initialQuestions]); + 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 ( -
+
@@ -356,7 +327,11 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC Form Settings Configure the main title and description of your form.
- +
@@ -367,7 +342,11 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC value={formTitle} onChange={(e) => handleFormTitleChange(e.target.value)} placeholder="e.g. Untitled Form" + aria-invalid={!formTitle.trim()} /> + {!formTitle.trim() && ( +

Form title is required.

+ )}
@@ -396,18 +375,52 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC

Questions

- +
-
-
+ + question.id)} + strategy={verticalListSortingStrategy} + > +
{questions.map((q, index) => ( - - + + + + + + +
+ + {stripHtml(q.label) || `Untitled question ${index + 1}`} + + + Question {index + 1} · {questionTypeLabel(q.type)} + {q.required ? " · Required" : ""} + +
+
+
+ +
- Short Answer - Paragraph - Choose one - Multiple choose + + Short answer + Paragraph + Choose one + Choose multiple + @@ -440,8 +455,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC className="h-8 w-8" disabled={index === 0} onClick={() => handleMove(index, "up")} + aria-label={`Move question ${index + 1} up`} > - +
@@ -470,10 +487,11 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
) : ( @@ -518,8 +536,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC size="icon" className="h-8 w-8 text-destructive" onClick={() => handleRemoveOption(q, optIdx)} + aria-label={`Remove option ${optIdx + 1}`} > - +
))} @@ -536,9 +555,6 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
)} - {/* Preview answer field */} - -
@@ -564,192 +580,146 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
)}
- + + + + + + + Delete question {index + 1}? + + This permanently removes the question and its stored answers. + + + + Keep question + void handleDeleteQuestion(q.id)} + > + Delete question + + + +
- + + + + + ))} - + + + ); } -function SaveStatusIndicator({ status }: { status: AutoSaveStatus }) { - if (status === "idle") return null; +function SaveStatusIndicator({ + status, + error, + onRetry, +}: { + status: AutoSaveStatus; + error: string | null; + onRetry: () => Promise; +}) { + if (status === "clean") return null; return ( - + + {status === "dirty" && "Unsaved"} {status === "saving" && ( <> - - Saving... + + Saving )} {status === "saved" && ( <> - + Saved )} + {status === "error" && ( + <> + + {error || "Could not save."} + + + )} ); } -function AnswerPreview({ question }: { question: Question }) { - const q = question; +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 ( -
- - - {q.type === "text" && ( - - )} - - {q.type === "textarea" && ( -