"use client"; /** * FormResponsesView — the TASK-0109 "Responses" surface for a published form * (lives inside the database's FORM view, next to the builder). * * API: GET /forms/:formId/submissions[?q&from&to&anonymousOnly&limit&cursor] * → { items: SubmissionDto[], nextCursor: string | null } * DELETE /forms/:formId/submissions/:id?confirm=true * → { deleted, recordDeleted, filesPurged } * * Design notes: * - WINDOWED LIST (no new dependency — the app has no virtualization lib): * rows have a deterministic height (desktop 48px, mobile 180px cards), so * a fixed-height window keeps the DOM to ~<30 rows while scrolling the * 100-row dataset (TASK-0109 acceptance). "Load more" appends the next * cursor page (server caps at 100 per request). * - IP: the server masks by role (`1.2.***.***` for Member, full for * Owner/Admin); the UI only displays `row.ip` and shows `ipMasked` as a * small lock hint when true. * - Submitter: `submittedBy === null` → "Anonymous"; a deleted user's row * still renders the server's "Former member" name. * - Record link: resolves the record via useDatabaseRecord then hands the * RecordDto to the parent's panel/page callbacks (the submission only * carries a recordId). * - Export stub: the button is DISABLED with a tooltip — the API answers * 501 { implementedIn: "phase-13" } (docs/api/forms.md §Export). * - Mobile (≤767px): the table becomes fixed-height answer cards; the * delete flow is identical. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ClipboardCopy, Download, ExternalLink, Inbox, Loader2, Search, Trash2 } from "lucide-react"; import { toast } from "@/stores/ui-store"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { apiFetch } from "@/lib/api"; import { RecordDto, useDatabaseRecord } from "@/hooks/use-database"; import { SubmissionDto, useDeleteSubmission, useSubmissions } from "@/hooks/use-forms"; // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- /** Render a raw answer value for a question type (display only). */ function formatAnswer(value: unknown, type: string): string { if (value == null) return "—"; const t = (type ?? "").toUpperCase(); if (t === "CHECKBOX") return value === true ? "Yes" : value === false ? "No" : String(value); if (t === "SINGLE_SELECT" || t === "STATUS" || t === "SELECT") { const s = Array.isArray(value) ? value[0] : value; return s == null ? "—" : String(s); } if (t === "MULTI_SELECT") { const s = Array.isArray(value) ? value : [value]; return s.map((x) => String(x)).join(", "); } if (t === "FILE") { const s = Array.isArray(value) ? value : [value]; return s.length ? `${s.length} file${s.length > 1 ? "s" : ""}` : "—"; } if (t === "DATE" || t === "DATE_RANGE") { const d = new Date(String(value)); return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); } if (typeof value === "string") return value; const s = JSON.stringify(value); return s.length > 80 ? `${s.slice(0, 77)}…` : s; } /** "label: value" pairs for a submission, in question order (map insertion). */ function answerPairs(s: SubmissionDto): { label: string; type: string; text: string }[] { return Object.entries(s.answers).map(([qid, value]) => { const q = s.questionLabels[qid]; const type = q?.type ?? ""; return { label: q?.label ?? "Question", type, text: formatAnswer(value, type) }; }); } function timeAgo(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } /** yyyy-mm-dd (date input) → ISO datetime bounds for the API query. */ function dateBounds(from?: string, to?: string): { from?: string; to?: string } { const out: { from?: string; to?: string } = {}; if (from) out.from = `${from}T00:00:00.000Z`; if (to) out.to = `${to}T23:59:59.999Z`; return out; } /** Small client-side debounce for the search box (the query refetches per key otherwise). */ function useDebounced(value: T, ms: number): T { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } function useMediaQuery(query: string): boolean { const [match, setMatch] = useState(false); useEffect(() => { const mq = window.matchMedia(query); setMatch(mq.matches); const onChange = (e: MediaQueryListEvent) => setMatch(e.matches); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, [query]); return match; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const ROW_H_DESKTOP = 48; // px — deterministic, so the window math is exact const ROW_H_MOBILE = 180; // px — fixed-height cards (line-clamped answers) const OVERSCAN = 6; const PAGE_SIZE = 50; export interface FormResponsesViewProps { formId: string; /** The form's public slug (the copy-link button → /f/). */ slug: string; databaseId: string; /** Open the record in the right-hand panel (the submission's recordId, resolved). */ onOpenRecord?: (record: RecordDto) => void; /** Navigate to the record's page (preferred over the panel when present). */ onOpenRecordPage?: (record: RecordDto) => void; } export function FormResponsesView({ formId, slug, databaseId, onOpenRecord, onOpenRecordPage }: FormResponsesViewProps) { const isMobile = useMediaQuery("(max-width: 767px)"); const rowH = isMobile ? ROW_H_MOBILE : ROW_H_DESKTOP; // ---- filters ------------------------------------------------------------- const [q, setQ] = useState(""); const [anonymousOnly, setAnonymousOnly] = useState(false); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [newestFirst, setNewestFirst] = useState(true); const debouncedQ = useDebounced(q, 300); const bounds = dateBounds(from, to); const filterActive = !!(debouncedQ || anonymousOnly || from || to); const { data, isLoading, isError, error } = useSubmissions(formId, { q: debouncedQ, anonymousOnly, ...bounds, limit: PAGE_SIZE }); // "Load more" — the cursor is opaque (a submission id); appending pages is // the only pagination surface. const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [loadingMore, setLoadingMore] = useState(false); // Reset the accumulated list whenever the query identity changes. const queryKey = JSON.stringify([debouncedQ, anonymousOnly, from, to]); useEffect(() => { setItems(data?.items ?? []); setNextCursor(data?.nextCursor ?? null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryKey, data]); const loadMore = useCallback(async () => { if (!nextCursor || loadingMore) return; setLoadingMore(true); try { const page = await fetchSubmissionsPage(formId, { q: debouncedQ, anonymousOnly, ...bounds, limit: PAGE_SIZE, cursor: nextCursor }); setItems((prev) => [...prev, ...page.items]); setNextCursor(page.nextCursor); } catch (e) { toast("error", "Load more failed", e instanceof Error ? e.message : undefined); } finally { setLoadingMore(false); } }, [nextCursor, loadingMore, formId, debouncedQ, anonymousOnly, bounds.from, bounds.to]); // Client-side sort on the loaded pages (the server orders by createdAt). const sorted = useMemo(() => { const list = [...items]; list.sort((a, b) => newestFirst ? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() : new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); return list; }, [items, newestFirst]); // ---- delete (confirm-gated; the API also requires ?confirm=true) --------- const [pendingDelete, setPendingDelete] = useState(null); const deleteSubmission = useDeleteSubmission(formId); const confirmDelete = () => { if (!pendingDelete) return; const sub = pendingDelete; deleteSubmission.mutate(sub.id, { onSuccess: (res) => { setPendingDelete(null); toast( "success", "Submission deleted", [ res.recordDeleted ? "record removed" : "record kept", res.filesPurged > 0 ? `${res.filesPurged} file(s) purged` : "" ] .filter(Boolean) .join(" · ") ); }, onError: (e) => toast("error", "Delete failed", e instanceof Error ? e.message : undefined) }); }; // ---- record link (resolve the submission's recordId → RecordDto) --------- const [linkingRecordId, setLinkingRecordId] = useState(null); const { data: linkedRecord } = useDatabaseRecord( linkingRecordId ? databaseId : undefined, linkingRecordId ?? undefined ); useEffect(() => { if (!linkedRecord) return; const record = linkedRecord; setLinkingRecordId(null); if (onOpenRecordPage) onOpenRecordPage(record); else if (onOpenRecord) onOpenRecord(record); // eslint-disable-next-line react-hooks/exhaustive-deps }, [linkedRecord]); // ---- copy the public link ------------------------------------------------- const copyLink = async () => { const url = `${window.location.origin}/f/${slug}`; try { await navigator.clipboard.writeText(url); toast("success", "Link copied", url); } catch { toast("error", "Could not copy the link", url); } }; // ---- windowed list -------------------------------------------------------- const scrollRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [viewH, setViewH] = useState(400); useEffect(() => { const el = scrollRef.current; if (!el) return; const ro = new ResizeObserver(() => setViewH(el.clientHeight)); ro.observe(el); setViewH(el.clientHeight); return () => ro.disconnect(); }, []); const total = sorted.length; const start = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN); const count = Math.ceil(viewH / rowH) + OVERSCAN * 2; const end = Math.min(total, start + count); const slice = sorted.slice(start, end); // ---- error state ---------------------------------------------------------- if (isError) { return (

Could not load responses

{error instanceof Error ? error.message : "Unknown error"}

); } return (
{/* toolbar: search + filters + sort + export stub */}
setQ(e.target.value)} placeholder="Search responses…" aria-label="Search responses" className="w-44 rounded-md border border-input bg-background py-1 pl-7 pr-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring sm:w-56" />
setFrom(e.target.value)} aria-label="From date" className="rounded-md border border-input bg-background px-2 py-1 text-xs" /> – setTo(e.target.value)} aria-label="To date" className="rounded-md border border-input bg-background px-2 py-1 text-xs" />
{filterActive && ( )} {/* Export stub — the API answers 501 { implementedIn: "phase-13" }. */}
{/* body — a BOUNDED scroll area (the app layout is natural-flow, so an unbounded flex-1 would render every row and defeat the window). The cap lets the windowed list keep its DOM to ~<30 rows while scrolling. */}
setScrollTop(e.currentTarget.scrollTop)} > {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : total === 0 && !filterActive ? ( // Empty state — the copy-link button is the primary affordance.

No responses yet.

Share the public link and responses will appear here as they arrive.

) : total === 0 ? (

No responses match the current filters.

) : ( <> {/* desktop header */} {!isMobile && (
Received Submitter Answers IP
)}
{slice.map((s) => { const pairs = answerPairs(s); return isMobile ? ( rid && setLinkingRecordId(rid)} onDelete={() => setPendingDelete(s)} /> ) : (
{timeAgo(s.createdAt)} `${p.label}: ${p.text}`).join("\n")}> {pairs .slice(0, 2) .map((p) => `${p.label}: ${p.text}`) .join(" · ")} {pairs.length > 2 ? ` +${pairs.length - 2}` : ""} {s.ipMasked && 🔒} {s.ip} {s.recordId && ( )}
); })}
{nextCursor && (
)} )}
{/* delete confirm (the API requires ?confirm=true; the gate is the UI) */} This permanently deletes the submission and its database record, and purges any attached files. This cannot be undone. } confirmLabel="Delete" destructive busy={deleteSubmission.isPending} onConfirm={confirmDelete} onClose={() => setPendingDelete(null)} />
); } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function SubmitterCell({ name, email }: { name: string; email?: string | null }) { return ( {name} {email && {email}} ); } function MobileCard({ row, pairs, rowH, onOpenRecord, onDelete }: { row: SubmissionDto; pairs: { label: string; type: string; text: string }[]; rowH: number; onOpenRecord: (recordId: string | null) => void; onDelete: () => void; }) { return (
{timeAgo(row.createdAt)}
    {pairs.slice(0, 4).map((p, i) => (
  • {p.label}: {p.text}
  • ))} {pairs.length > 4 &&
  • +{pairs.length - 4} more
  • }
{row.ipMasked && 🔒} {row.ip} {row.recordId && ( )}
); } // --------------------------------------------------------------------------- // "Load more" fetch (the cursor continues the SAME filtered query the hook // opened; the query hook owns the first page, this helper fetches the rest). // --------------------------------------------------------------------------- async function fetchSubmissionsPage( formId: string, f: { q?: string; anonymousOnly?: boolean; from?: string; to?: string; limit?: number; cursor?: string } ): Promise<{ items: SubmissionDto[]; nextCursor: string | null }> { const params = new URLSearchParams(); if (f.q) params.set("q", f.q); if (f.anonymousOnly) params.set("anonymousOnly", "true"); if (f.from) params.set("from", f.from); if (f.to) params.set("to", f.to); if (f.limit) params.set("limit", String(f.limit)); if (f.cursor) params.set("cursor", f.cursor); return apiFetch(`/forms/${formId}/submissions?${params.toString()}`); }