Let admins edit, deactivate and delete forms

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:26:42 -04:00
co-authored by Claude Opus 5
parent 9360bf3055
commit 53ab6b92b5
+368 -57
View File
@@ -6,103 +6,350 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch";
import { Plus, Trash2, FileText } from "lucide-react"; 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 { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" }; 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")({ export const Route = createFileRoute("/_authenticated/forms")({
head: () => ({ meta: [{ title: "Forms — School Portal" }] }), head: () => ({ meta: [{ title: "Forms — School Portal" }] }),
component: FormsPage, component: FormsPage,
}); });
const emptyField: Field = { label: "", type: "text" };
function FormsPage() { function FormsPage() {
const { user, roles } = useAuth(); const { roles } = useAuth();
const isAdmin = roles.includes("admin"); const isAdmin = roles.includes("admin");
const qc = useQueryClient(); const qc = useQueryClient();
const [activeForm, setActiveForm] = useState<string | null>(null); const [activeForm, setActiveForm] = useState<string | null>(null);
const [editing, setEditing] = useState<FormRow | null>(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({ const { data: forms } = useQuery({
queryKey: ["forms"], queryKey: ["forms", isAdmin],
queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [], 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<string, number> = {};
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 ( return (
<div className="p-8 max-w-5xl"> <div className="p-8 max-w-5xl">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-semibold">Forms</h1> <h1 className="text-2xl font-semibold">Forms</h1>
<p className="text-muted-foreground text-sm">Push a form to families; they complete it in the portal.</p> <p className="text-muted-foreground text-sm">
Push a form to families; they complete it in the portal.
</p>
</div> </div>
{isAdmin && <FormBuilder onCreated={() => qc.invalidateQueries({ queryKey: ["forms"] })} />} {isAdmin && (
<Button onClick={() => setCreating(true)}>
<Plus className="h-4 w-4 mr-1" /> New form
</Button>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(forms ?? []).map((f) => ( {(forms ?? []).map((f) => {
<button key={f.id} onClick={() => setActiveForm(f.id)} className="text-left bg-card border rounded-lg p-4 hover:bg-muted/30"> const n = counts?.[f.id] ?? 0;
<FileText className="h-5 w-5 text-primary mb-2" /> return (
<div className="font-medium">{f.title}</div> <div
{f.description && <div className="text-sm text-muted-foreground mt-1">{f.description}</div>} key={f.id}
<div className="text-xs text-muted-foreground mt-2">{(f.schema_json as Field[])?.length ?? 0} field(s)</div> className={`bg-card border rounded-lg p-4 ${f.active ? "" : "opacity-70"}`}
</button> >
))} <button onClick={() => setActiveForm(f.id)} className="text-left w-full">
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No active forms.</div>} <FileText className="h-5 w-5 text-primary mb-2" />
<div className="font-medium flex items-center gap-2">
{f.title}
{!f.active && <Badge variant="secondary">Inactive</Badge>}
</div>
{f.description && (
<div className="text-sm text-muted-foreground mt-1">{f.description}</div>
)}
<div className="text-xs text-muted-foreground mt-2">
{(f.schema_json as Field[])?.length ?? 0} field(s)
{isAdmin && ` · ${n} response${n === 1 ? "" : "s"}`}
</div>
</button>
{isAdmin && (
<div className="flex gap-1 mt-3 pt-3 border-t">
<Button size="sm" variant="outline" onClick={() => setEditing(f)}>
<Pencil className="h-4 w-4 mr-1" /> Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setActive.mutate({ id: f.id, active: !f.active })}
disabled={setActive.isPending}
>
{f.active ? (
<>
<EyeOff className="h-4 w-4 mr-1" /> Deactivate
</>
) : (
<>
<Eye className="h-4 w-4 mr-1" /> Activate
</>
)}
</Button>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => confirmDelete(f)}
disabled={remove.isPending}
title={n > 0 ? `Also deletes ${n} response(s)` : "Delete form"}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No forms yet.</div>}
</div> </div>
{activeForm && <FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />} {creating && (
<FormEditor
onDone={() => {
setCreating(false);
refresh();
}}
/>
)}
{editing && (
<FormEditor
form={editing}
responseCount={counts?.[editing.id] ?? 0}
onDone={() => {
setEditing(null);
refresh();
}}
/>
)}
{activeForm && (
<FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />
)}
</div> </div>
); );
} }
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 { user } = useAuth();
const [open, setOpen] = useState(false); const isEdit = !!form;
const [title, setTitle] = useState(""); const [title, setTitle] = useState(form?.title ?? "");
const [description, setDescription] = useState(""); const [description, setDescription] = useState(form?.description ?? "");
const [fields, setFields] = useState<Field[]>([{ label: "Full name", type: "text" }]); const [active, setActive] = useState(form?.active ?? true);
const [fields, setFields] = useState<Field[]>(
(form?.schema_json as Field[]) ?? [{ label: "Full name", type: "text" }],
);
const create = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id }); const clean = fields.filter((f) => f.label.trim() !== "");
if (error) throw error; 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), onError: (e: Error) => toast.error(e.message),
}); });
const setField = (i: number, patch: Partial<Field>) =>
setFields((fs) => fs.map((f, j) => (j === i ? { ...f, ...patch } : f)));
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open onOpenChange={(o) => !o && onDone()}>
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New form</Button></DialogTrigger>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader><DialogTitle>Create form</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? "Edit form" : "Create form"}</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[70vh] overflow-auto"> <div className="space-y-3 max-h-[70vh] overflow-auto">
<div><Label>Title</Label><Input value={title} onChange={(e) => setTitle(e.target.value)} /></div> {/* Responses store answers keyed by field label, so a rename leaves the
<div><Label>Description</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} /></div> old answers stranded under the old key. Say so rather than silently
breaking the response view. */}
{isEdit && responseCount > 0 && (
<div className="flex items-start gap-2 text-xs border rounded-md p-3">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 mt-0.5" />
<span>
This form already has {responseCount} response{responseCount === 1 ? "" : "s"}.
Answers are stored under their field label, so renaming or removing a field won't
change what was already submitted — those answers stay under the old label. Adding
fields is always safe.
</span>
</div>
)}
<div>
<Label>Title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<Label>Description</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="flex items-center justify-between max-w-sm">
<Label className="text-xs">Active — visible to families</Label>
<Switch checked={active} onCheckedChange={setActive} />
</div>
<div> <div>
<Label>Fields</Label> <Label>Fields</Label>
<div className="space-y-2 mt-1"> <div className="space-y-2 mt-1">
{fields.map((f, i) => ( {fields.map((f, i) => (
<div key={i} className="flex gap-2"> <div key={i} className="flex gap-2">
<Input className="flex-1" value={f.label} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], label: e.target.value }; setFields(c); }} placeholder="Field label" /> <Input
<select className="border rounded-md px-2 text-sm" value={f.type} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], type: e.target.value as Field["type"] }; setFields(c); }}> className="flex-1"
<option value="text">Short text</option><option value="textarea">Long text</option><option value="yesno">Yes / No</option><option value="date">Date</option> value={f.label}
onChange={(e) => setField(i, { label: e.target.value })}
placeholder="Field label"
/>
<select
className="border rounded-md px-2 text-sm"
value={f.type}
onChange={(e) => setField(i, { type: e.target.value as Field["type"] })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
<option value="yesno">Yes / No</option>
<option value="date">Date</option>
</select> </select>
<Button size="icon" variant="ghost" onClick={() => setFields(fields.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button> <Button
size="icon"
variant="ghost"
onClick={() => setFields(fields.filter((_, j) => j !== i))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
))} ))}
<Button variant="outline" size="sm" onClick={() => setFields([...fields, { label: "", type: "text" }])}><Plus className="h-4 w-4 mr-1" /> Add field</Button> <Button
variant="outline"
size="sm"
onClick={() => setFields([...fields, { ...emptyField }])}
>
<Plus className="h-4 w-4 mr-1" /> Add field
</Button>
</div> </div>
</div> </div>
<Button className="w-full" disabled={!title || create.isPending} onClick={() => create.mutate()}>Create form</Button> <div className="flex gap-2">
<Button
className="flex-1"
disabled={!title || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : isEdit ? "Save changes" : "Create form"}
</Button>
<Button variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose: () => void; isAdmin: boolean }) { function FormFillDialog({
formId,
onClose,
isAdmin,
}: {
formId: string;
onClose: () => void;
isAdmin: boolean;
}) {
const { user, roles } = useAuth(); const { user, roles } = useAuth();
const isParent = roles.includes("parent"); const isParent = roles.includes("parent");
const qc = useQueryClient(); const qc = useQueryClient();
@@ -117,8 +364,13 @@ function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose:
const { data: myStudents } = useQuery({ const { data: myStudents } = useQuery({
queryKey: ["my-students", user?.id], queryKey: ["my-students", user?.id],
queryFn: async () => { queryFn: async () => {
const { data } = await supabase.from("parent_students").select("student_id, students(id, first_name, last_name)").eq("parent_id", user!.id); const { data } = await supabase
return (data ?? []).map((d) => d.students as { id: string; first_name: string; last_name: string }); .from("parent_students")
.select("student_id, students(id, first_name, last_name)")
.eq("parent_id", user!.id);
return (data ?? []).map(
(d) => d.students as { id: string; first_name: string; last_name: string },
);
}, },
enabled: !!user, enabled: !!user,
}); });
@@ -126,9 +378,15 @@ function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose:
const { data: responses } = useQuery({ const { data: responses } = useQuery({
queryKey: ["form-responses", formId], queryKey: ["form-responses", formId],
queryFn: async () => { queryFn: async () => {
const { data } = await supabase.from("form_responses").select("*, students(first_name, last_name)").eq("form_id", formId).order("submitted_at", { ascending: false }); const { data } = await supabase
.from("form_responses")
.select("*, students(first_name, last_name)")
.eq("form_id", formId)
.order("submitted_at", { ascending: false });
const ids = Array.from(new Set((data ?? []).map((r) => r.submitted_by))); const ids = Array.from(new Set((data ?? []).map((r) => r.submitted_by)));
const { data: profs } = ids.length ? await supabase.from("profiles").select("id, full_name").in("id", ids) : { data: [] }; const { data: profs } = ids.length
? await supabase.from("profiles").select("id, full_name").in("id", ids)
: { data: [] };
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name])); const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
return (data ?? []).map((r) => ({ ...r, submitter_name: map[r.submitted_by] ?? "—" })); return (data ?? []).map((r) => ({ ...r, submitter_name: map[r.submitted_by] ?? "—" }));
}, },
@@ -137,10 +395,20 @@ function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose:
const submit = useMutation({ const submit = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { error } = await supabase.from("form_responses").insert({ form_id: formId, student_id: studentId || null, submitted_by: user!.id, data_json: values }); const { error } = await supabase.from("form_responses").insert({
form_id: formId,
student_id: studentId || null,
submitted_by: user!.id,
data_json: values,
});
if (error) throw error; if (error) throw error;
}, },
onSuccess: () => { toast.success("Submitted"); qc.invalidateQueries({ queryKey: ["form-responses", formId] }); onClose(); }, onSuccess: () => {
toast.success("Submitted");
qc.invalidateQueries({ queryKey: ["form-responses", formId] });
qc.invalidateQueries({ queryKey: ["form-response-counts"] });
onClose();
},
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
@@ -149,32 +417,64 @@ function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose:
return ( return (
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto"> <DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
<DialogHeader><DialogTitle>{form?.title}</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>{form?.title}</DialogTitle>
</DialogHeader>
{form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>} {form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>}
{form && !form.active && (
<p className="text-xs text-muted-foreground">
This form is inactive — families cannot see or submit it.
</p>
)}
<div className="space-y-3 mt-2"> <div className="space-y-3 mt-2">
{isParent && myStudents && myStudents.length > 0 && ( {isParent && myStudents && myStudents.length > 0 && (
<div> <div>
<Label>For student</Label> <Label>For student</Label>
<select className="w-full border rounded-md p-2 text-sm" value={studentId} onChange={(e) => setStudentId(e.target.value)}> <select
className="w-full border rounded-md p-2 text-sm"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
>
<option value="">Select…</option> <option value="">Select…</option>
{myStudents.map((s) => <option key={s.id} value={s.id}>{s.first_name} {s.last_name}</option>)} {myStudents.map((s) => (
<option key={s.id} value={s.id}>
{s.first_name} {s.last_name}
</option>
))}
</select> </select>
</div> </div>
)} )}
{fields.map((f, i) => ( {fields.map((f, i) => (
<div key={i}> <div key={i}>
<Label>{f.label}</Label> <Label>{f.label}</Label>
{f.type === "textarea" ? <Textarea value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} /> : {f.type === "textarea" ? (
f.type === "yesno" ? ( <Textarea
<select className="w-full border rounded-md p-2 text-sm" value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}> value={values[f.label] ?? ""}
<option value="">—</option><option value="yes">Yes</option><option value="no">No</option> onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
/>
) : f.type === "yesno" ? (
<select
className="w-full border rounded-md p-2 text-sm"
value={values[f.label] ?? ""}
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
>
<option value="">—</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select> </select>
) : ) : (
<Input type={f.type === "date" ? "date" : "text"} value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} />} <Input
type={f.type === "date" ? "date" : "text"}
value={values[f.label] ?? ""}
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
/>
)}
</div> </div>
))} ))}
<Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>Submit</Button> <Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>
Submit
</Button>
</div> </div>
{isAdmin && ( {isAdmin && (
@@ -184,14 +484,25 @@ function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose:
{(responses ?? []).map((r) => ( {(responses ?? []).map((r) => (
<div key={r.id} className="border rounded-md p-3 text-sm"> <div key={r.id} className="border rounded-md p-3 text-sm">
<div className="font-medium"> <div className="font-medium">
{(r.students as { first_name: string; last_name: string } | null)?.first_name ?? "—"} {(r.students as { last_name?: string } | null)?.last_name ?? ""} {(r.students as { first_name: string; last_name: string } | null)?.first_name ??
<span className="text-muted-foreground font-normal ml-2">by {r.submitter_name} · {new Date(r.submitted_at).toLocaleDateString()}</span> "—"}{" "}
{(r.students as { last_name?: string } | null)?.last_name ?? ""}
<span className="text-muted-foreground font-normal ml-2">
by {r.submitter_name} · {new Date(r.submitted_at).toLocaleDateString()}
</span>
</div> </div>
<div className="mt-1 text-xs space-y-0.5"> <div className="mt-1 text-xs space-y-0.5">
{Object.entries(r.data_json as Record<string, string>).map(([k, v]) => <div key={k}><span className="text-muted-foreground">{k}:</span> {v}</div>)} {Object.entries(r.data_json as Record<string, string>).map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span> {v}
</div>
))}
</div> </div>
</div> </div>
))} ))}
{responses?.length === 0 && (
<p className="text-sm text-muted-foreground">No responses yet.</p>
)}
</div> </div>
</div> </div>
)} )}