import { useCallback, useEffect, useRef, useState } from 'react'; import { Inbox, Loader2, Paperclip, TriangleAlert } from 'lucide-react'; import { FORM_SUBMISSION_STATUSES, FORM_SUBMISSION_STATUS_LABELS, FormSubmission, FormSubmissionStatus, useFormSubmissionsService, } from '../../../services/FormSubmissionsService.tsx'; import { PublishedPublicForm, usePublicFormService } from '../../../services/PublicFormService.tsx'; import { useFormSubmissionNotificationContext } from '../../../services/FormSubmissionNotificationService.tsx'; import { EmptyState } from '../../../components/ui/empty-state.tsx'; import FormSubmissionDetailDialog from './FormSubmissionDetailDialog.tsx'; import { submissionFormLabel } from './formLabel.ts'; import { SubmissionStatus, fullWhen, rowWhen } from './submissionDisplay.tsx'; import './FormSubmissionsPage.css'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; type StatusFilter = 'all' | FormSubmissionStatus; const FILTERS: StatusFilter[] = ['all', ...FORM_SUBMISSION_STATUSES]; const FILTER_LABELS: Record = { all: 'All', ...FORM_SUBMISSION_STATUS_LABELS, }; const PAGE_SIZE = 25; // Best-effort preview of a submission's content for the list row. Prefers the free-text `message`. Not // used by the detail dialog, which renders every answer individually. /** * EVERY ROW PRINTED THE SAME VALUE TWICE, and the fallback is why. * * Observed on Great River: four submissions, and on all four the title line and the snippet line read * identically - *"Zztest Marcus"* above *"Zztest Marcus"*. **A two-line row exists to carry two facts; * this one spent both on the same one.** * * THE CAUSE IS NOT THE SNIPPET, IT IS THE FALLBACK. With no `message`, this took `answers[0]` * blindly - and the first question on an intake form is the person's name, which the line above is * already showing. A rule that says *"show the first answer"* cannot know it is about to repeat the * title. * * SO IT SKIPS ANY ANSWER THE ROW IS ALREADY SHOWING. Not "skip the first answer" - forms differ, and * a practice whose first question is *"What can we help with?"* should still see it. * * CORRECTED 2026-08-23, AND ONLY A BROWSER COULD HAVE FOUND IT. Skipping the exact title was not * enough. Measured on Great River after the first fix shipped: * * title Zztest <- submitterContact.name holds the FIRST NAME ONLY * snippet Turnstilefix <- so the rule skipped answer 1 and landed on the SURNAME * answers Zztest - Turnstilefix - zztest...@example.test - " - re-run of browser run A..." * * The row still spent both lines on the person's name, just split across them - **the card's own * complaint surviving its own fix in a different shape.** The message was the FOURTH answer. * * SO IT SKIPS THE IDENTITY THE ROW IS ABOUT - every word of the title and the submitter's email - * and then prefers the answer with the MOST TO SAY. A free-text message is essentially always longer * than a name, an email or a phone number, and length needs no question labels: matching on phrasing * ("How can we help?") finds a different set on every form, which is the trap this card started in. * * @param alreadyShown what the title line renders, so the snippet can avoid saying it again. * @param alsoShown other identity the row is about - the email - which is never the message either. */ const snippetFor = (submission: FormSubmission, alreadyShown?: string, alsoShown?: string): string => { if (submission.message) { return submission.message; } /* * The WHOLE title AND each of its words, plus the email. * * BOTH, AND THE SUITE CAUGHT ME DROPPING ONE. Words alone were the fix for the measured case - the * title is the first name and the surname is a separate answer. But a form with a single "Name" * question answers it with the FULL name, which is not any one word, so switching to words alone * un-skipped the exact duplicate this card started with. The set holds both forms. */ const title = (alreadyShown ?? '').trim(); const identity = new Set( [title, ...title.split(/\s+/), alsoShown ?? ''] .map((part) => part.trim().toLowerCase()) .filter(Boolean), ); const readable = (submission.answers ?? []) .map((answer) => { const value = answer?.value; if (value === null || value === undefined || value === '') return ''; if (typeof value === 'object') { try { return JSON.stringify(value); } catch { return ''; } } return String(value); }) .filter((value) => value !== '' && !identity.has(value.trim().toLowerCase())); if (readable.length === 0) { return 'No message provided.'; } // THE LONGEST, not the first. Position told us nothing - the message sat behind three identity // fields - and "most to say" is the one property a message reliably has. return readable.reduce((longest, value) => (value.length > longest.length ? value : longest)); }; const truncate = (value: string, max = 140): string => value.length > max ? `${value.slice(0, max).trimEnd()}…` : value; const FormSubmissionsList = () => { const { fetchSubmissions } = useFormSubmissionsService(); const { listPublicForms } = usePublicFormService(); const { refresh: refreshFormSubmissionNotifications, newFormSubmissions } = useFormSubmissionNotificationContext(); const [filter, setFilter] = useState('all'); const [items, setItems] = useState([]); const [nextToken, setNextToken] = useState(); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); // A failed first read is its own state: it must never render as "no submissions". const [loadError, setLoadError] = useState(); const [moreError, setMoreError] = useState(); const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); // Only the newest filter's read may land; a slower earlier one is dropped. const readSeq = useRef(0); /** * HANDLE PUBLISHED TITLE, so a row can say which form it came from. * * SEPARATE AND BEST-EFFORT, deliberately. The submissions list is the point of this screen and it * must render whether or not this resolves - a practice whose form list fails to load should still * see its submissions, with the handle standing in for the title. No spinner, no error, no gate. */ const [formTitles, setFormTitles] = useState>({}); useEffect(() => { let cancelled = false; void listPublicForms() .then((forms: PublishedPublicForm[]) => { if (cancelled) return; setFormTitles(Object.fromEntries( forms .filter((form) => form.title) .map((form) => [form.formHandle, form.title as string]), )); }) // Swallowed on purpose: a failure here costs a nicer label, not the list. .catch(() => undefined); return () => { cancelled = true; }; }, [listPublicForms]); const load = useCallback( async (status: StatusFilter) => { const seq = ++readSeq.current; setLoading(true); setLoadError(undefined); setMoreError(undefined); setLoadingMore(false); setItems([]); setNextToken(undefined); try { const result = await fetchSubmissions({ status: status === 'all' ? undefined : status, limit: PAGE_SIZE, }); if (seq !== readSeq.current) return; setItems(result.items); setNextToken(result.nextToken); } catch (err) { if (seq !== readSeq.current) return; console.error('Failed to load form submissions:', err); setLoadError(relayServerFailure(err, 'The submissions could not be loaded.')); } finally { if (seq === readSeq.current) setLoading(false); } }, [fetchSubmissions], ); useEffect(() => { void load(filter); }, [filter, load]); // Unmount invalidates pending reads so a late continuation cannot write to the gone page. useEffect(() => () => { readSeq.current += 1; }, []); // A submission that arrives while the list is open: the notification provider re-reads on the // broadcast, and a change in its new set re-reads this list's first page without a spinner, // putting unseen rows on top and refreshing known ones, so loaded pages and the open row stay. const arrivals = (newFormSubmissions ?? []).map((s) => s.submissionId).sort().join(','); const seenArrivals = useRef(arrivals); useEffect(() => { if (arrivals === seenArrivals.current || loading) return; seenArrivals.current = arrivals; const seq = readSeq.current; void fetchSubmissions({ status: filter === 'all' ? undefined : filter, limit: PAGE_SIZE }) .then((result) => { if (seq !== readSeq.current) return; setItems((current) => { const fresh = new Map(result.items.map((s) => [s.submissionId, s])); const known = new Set(current.map((s) => s.submissionId)); const added = result.items.filter((s) => !known.has(s.submissionId)); return [...added, ...current.map((s) => fresh.get(s.submissionId) ?? s)]; }); }) // The open list stays as it was; the next broadcast or visit reads again. .catch(() => undefined); }, [arrivals, loading, filter, fetchSubmissions]); const loadMore = async () => { if (!nextToken || loadingMore) { return; } const seq = readSeq.current; setLoadingMore(true); setMoreError(undefined); try { const result = await fetchSubmissions({ status: filter === 'all' ? undefined : filter, limit: PAGE_SIZE, nextToken, }); if (seq !== readSeq.current) return; setItems((current) => [...current, ...result.items]); setNextToken(result.nextToken); } catch (err) { if (seq !== readSeq.current) return; console.error('Failed to load more form submissions:', err); setMoreError(relayServerFailure(err, 'More submissions could not be loaded.')); } finally { if (seq === readSeq.current) setLoadingMore(false); } }; const openDetail = (submission: FormSubmission) => { setSelected(submission); setDetailOpen(true); }; // A save can land after its overlay closed, so it updates that submission's row and only // replaces the open one when it is the same submission. const handleUpdated = (updated: FormSubmission) => { setItems((current) => current.map((item) => (item.submissionId === updated.submissionId ? updated : item)), ); setSelected((current) => (current?.submissionId === updated.submissionId ? updated : current)); void refreshFormSubmissionNotifications(); }; const failure = (title: string, message: string, retry: () => void) => (
); return (

Form Submissions

{FILTERS.map((value) => ( ))}
{loading ? (
Loading submissions…
) : loadError ? ( failure('Submissions not loaded', loadError, () => void load(filter)) ) : items.length === 0 ? ( // Zero rows on a page with a token is a partial read, not proof nothing was submitted. nextToken && !moreError ? ( ) : moreError ? null : ( ) ) : (
{items.map((submission) => ( ))}
)} {moreError && !loading && failure('More submissions not loaded', moreError, () => void loadMore())} {nextToken && !loading && !loadError && (
)} { setDetailOpen(open); if (!open) { setSelected(null); } }} onUpdated={handleUpdated} />
); }; // A demo visit has no public forms and the server refuses them, so the page asks for nothing there. const FormSubmissionsPage = () => { const { newFormSubmissionsStatus } = useFormSubmissionNotificationContext(); if (newFormSubmissionsStatus !== 'unavailable') return ; return (

Form Submissions

Unavailable in this demo.

); }; export default FormSubmissionsPage;