From 53ab6b92b53a7062e680a1b228cb6f0f7c76d7c5 Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 26 Jul 2026 10:26:42 -0400 Subject: [PATCH] Let admins edit, deactivate and delete forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database already permitted all three (forms admin manage is FOR ALL); this was purely a missing UI. Create and edit now share one editor, since the only differences are the initial values and insert vs update. Three hazards in the existing data model shaped this rather than just adding buttons: - form_responses.form_id is ON DELETE CASCADE, so deleting a form permanently destroys its submissions. The confirmation names the response count and points to deactivating instead. Verified: deleting a form with two responses leaves zero. - The list filtered active = true for everyone, so deactivating a form hid it from the only people who could reactivate it. Admins now see inactive forms with a badge; families still see only active ones. - Responses key their answers by field *label* in data_json, so renaming a field strands existing answers under the old key. The editor warns when the form already has responses. Deliberately not migrating old answers — guessing which old label maps to which new one would risk silently rewriting submitted data. Also strips fields with blank labels on save, shows a response count per form, and gives the response list an empty state. Co-Authored-By: Claude Opus 5 --- src/routes/_authenticated/forms.tsx | 425 ++++++++++++++++++++++++---- 1 file changed, 368 insertions(+), 57 deletions(-) diff --git a/src/routes/_authenticated/forms.tsx b/src/routes/_authenticated/forms.tsx index 1740f47..bef5bd6 100644 --- a/src/routes/_authenticated/forms.tsx +++ b/src/routes/_authenticated/forms.tsx @@ -6,103 +6,350 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Plus, Trash2, FileText } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Trash2, FileText, Pencil, EyeOff, Eye, AlertTriangle } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" }; +type FormRow = { + id: string; + title: string; + description: string | null; + schema_json: unknown; + active: boolean; +}; export const Route = createFileRoute("/_authenticated/forms")({ head: () => ({ meta: [{ title: "Forms — School Portal" }] }), component: FormsPage, }); +const emptyField: Field = { label: "", type: "text" }; + function FormsPage() { - const { user, roles } = useAuth(); + const { roles } = useAuth(); const isAdmin = roles.includes("admin"); const qc = useQueryClient(); const [activeForm, setActiveForm] = useState(null); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + // Admins see inactive forms too, otherwise deactivating one would hide it from + // the only people who could bring it back. const { data: forms } = useQuery({ - queryKey: ["forms"], - queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [], + queryKey: ["forms", isAdmin], + queryFn: async () => { + let q = supabase.from("forms").select("*"); + if (!isAdmin) q = q.eq("active", true); + return ((await q.order("created_at", { ascending: false })).data ?? []) as FormRow[]; + }, }); + // Response counts drive the delete warning — form_responses cascades on + // delete, so removing a form destroys its submissions. + const { data: counts } = useQuery({ + queryKey: ["form-response-counts"], + enabled: isAdmin, + queryFn: async () => { + const { data } = await supabase.from("form_responses").select("form_id"); + const map: Record = {}; + for (const r of data ?? []) map[r.form_id] = (map[r.form_id] ?? 0) + 1; + return map; + }, + }); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["forms"] }); + qc.invalidateQueries({ queryKey: ["form-response-counts"] }); + }; + + const setActive = useMutation({ + mutationFn: async ({ id, active }: { id: string; active: boolean }) => { + const { error } = await supabase.from("forms").update({ active }).eq("id", id); + if (error) throw error; + return active; + }, + onSuccess: (active) => { + toast.success(active ? "Form activated" : "Form deactivated"); + refresh(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const remove = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from("forms").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Form deleted"); + refresh(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const confirmDelete = (f: FormRow) => { + const n = counts?.[f.id] ?? 0; + const msg = + n > 0 + ? `Delete "${f.title}"?\n\nThis also permanently deletes ${n} submitted response${n === 1 ? "" : "s"}. That cannot be undone.\n\nTo stop families seeing the form while keeping its responses, deactivate it instead.` + : `Delete "${f.title}"? This cannot be undone.`; + if (confirm(msg)) remove.mutate(f.id); + }; + return (

Forms

-

Push a form to families; they complete it in the portal.

+

+ Push a form to families; they complete it in the portal. +

- {isAdmin && qc.invalidateQueries({ queryKey: ["forms"] })} />} + {isAdmin && ( + + )}
- {(forms ?? []).map((f) => ( - - ))} - {forms?.length === 0 &&
No active forms.
} + {(forms ?? []).map((f) => { + const n = counts?.[f.id] ?? 0; + return ( +
+ + + {isAdmin && ( +
+ + +
+ +
+ )} +
+ ); + })} + {forms?.length === 0 &&
No forms yet.
}
- {activeForm && setActiveForm(null)} isAdmin={isAdmin} />} + {creating && ( + { + setCreating(false); + refresh(); + }} + /> + )} + {editing && ( + { + setEditing(null); + refresh(); + }} + /> + )} + {activeForm && ( + setActiveForm(null)} isAdmin={isAdmin} /> + )}
); } -function FormBuilder({ onCreated }: { onCreated: () => void }) { +// Create and edit share one editor: the only differences are the initial values +// and whether we insert or update. +function FormEditor({ + form, + responseCount = 0, + onDone, +}: { + form?: FormRow; + responseCount?: number; + onDone: () => void; +}) { const { user } = useAuth(); - const [open, setOpen] = useState(false); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [fields, setFields] = useState([{ label: "Full name", type: "text" }]); + const isEdit = !!form; + const [title, setTitle] = useState(form?.title ?? ""); + const [description, setDescription] = useState(form?.description ?? ""); + const [active, setActive] = useState(form?.active ?? true); + const [fields, setFields] = useState( + (form?.schema_json as Field[]) ?? [{ label: "Full name", type: "text" }], + ); - const create = useMutation({ + const save = useMutation({ mutationFn: async () => { - const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id }); - if (error) throw error; + const clean = fields.filter((f) => f.label.trim() !== ""); + if (clean.length === 0) throw new Error("Add at least one field with a label"); + if (isEdit) { + const { error } = await supabase + .from("forms") + .update({ title, description, schema_json: clean, active }) + .eq("id", form!.id); + if (error) throw error; + } else { + const { error } = await supabase + .from("forms") + .insert({ title, description, schema_json: clean, active, created_by: user!.id }); + if (error) throw error; + } + }, + onSuccess: () => { + toast.success(isEdit ? "Form updated" : "Form created"); + onDone(); }, - onSuccess: () => { setOpen(false); setTitle(""); setDescription(""); setFields([{ label: "Full name", type: "text" }]); onCreated(); toast.success("Form created"); }, onError: (e: Error) => toast.error(e.message), }); + const setField = (i: number, patch: Partial) => + setFields((fs) => fs.map((f, j) => (j === i ? { ...f, ...patch } : f))); + return ( - - + !o && onDone()}> - Create form + + {isEdit ? "Edit form" : "Create form"} +
-
setTitle(e.target.value)} />
-