Forms UI
The interaction contract for the Phase-10 Forms feature in
apps/web(TASK-0106/0107/0109). The API contract lives in ../api/forms.md (authoritative). Component map:
components/form/form-renderer.tsx— the public form page: fetch → validate → submit → success/closed/not-available states (shared with the builder preview)components/form/form-field.tsx— the per-type input (one switch over the 10 question types)components/database/form-view.tsx— the FORM view tab inside a database page: Builder / Responses tabs + the/f/<slug>chipcomponents/database/form-builder-view.tsx— the builder (question list, add/reorder/edit, the filtered property-mapping select, branding, publish/close)components/form/form-responses-view.tsx— the responses list (windowed rows, mobile answer cards, delete + confirm)app/f/[slug]/page.tsx— the standalone public route (no workspace layout, no sidebar — favicon + accent fromForm.config)hooks/use-forms.ts—useForm,useCreateForm,useUpdateForm,useSubmissions,useDeleteSubmission,questionOptions,withOptionshooks/use-database.ts—flattenDatabase(normalizes the nestedGET /databases/:idenvelope) + the view/property/record hooks
1. The FORM view (builder host)
A form is hosted by a FORM view on its database (view.type === "FORM",
view.config.formId). The view tab renders components/database/form-view.tsx:
- No
formIdin the view config → the empty state (form-view-create) with the "Create form" CTA (creates a form for this database and points the view at it). - With a
formId→ a two-tab bar:form-view-tab-builder— the builder (§2).form-view-tab-responses— the responses list (§4, owner/editor only in the app; the public page never shows it).- A
/f/<slug>chip +form-view-copy-linkcopies the public URL.
- Content is gated on
useForm(formId)— while loading it shows "Loading form…".
The database page's ?view= deep link is preserved while the view list is
still loading (the URL-sync effect returns early until an active view id
resolves) — so /w/<ws>/databases/<db>?view=<formViewId> opens directly on
the FORM view.
2. The builder (TASK-0106)
components/database/form-builder-view.tsx.
- Question list — rows in
positionorder, drag/reorder viaPOST /forms/:formId/questions/reorder(fromId+beforeId); add a question (type picker over the 10 types), edit label/description/required/ placeholder/validation, delete. - Property mapping — each question gets a select filtered to the
compatible property types
(
QUESTION_PROPERTY_COMPATIBILITY[q.type]from@factory/shared/forms.ts) plus "Don't map" (store inanswersonly). The matrix is enforced server-side too (422PROPERTY_TYPE_MISMATCH), so a UI bug can never map an incompatible type. Selecting a SELECT/STATUS property pre-fills the question options from the property config. - Select options — edited as a list of names; stored on the wire as a
string[](validation.options = ["Praise", …]) — the server's contract.questionOptions()(inuse-forms.ts) normalizes the string array into the web'sFormOptiondisplay objects on read; the builder emits names on write. (See the Phase-10 conflict log — an object-array drift broke option rendering.) - Branding — accent (from
FORM_ACCENTS), intro (markdown, sanitized on the server), icon, success/closed messages,poweredBy, recaptcha stub. - Publish/close — "Publish" calls
PATCH /forms/:formId { status: "PUBLISHED" }; the server rejects a 0-question publish with 422NO_QUESTIONS(surfaced as an issue list). "Close" →CLOSED(the public page flips to its closed state). DRAFT forms are invisible publicly.
3. The public page — /f/<slug> (TASK-0107)
app/f/[slug]/page.tsx → <FormRenderer slug=…/>. Standalone route outside
the workspace app group: no auth, no workspace layout, no sidebar — a
minimal shell (favicon + accent color from Form.config).
- States: loading (spinner) → published (the form) / closed
(
"Form closed"+closedMessage) / not available (DRAFT or unknown slug — the API 404s; the page renders the "This form is not available." notice, never a status leak). - Per-type inputs (
form-field.tsx, one switch over the 10 types): short/long text (textarea for long), number (step 1, min/max attrs), email (type=email), date (native), checkbox, single select (<select>), multi select (chips), rating (1..max buttons), file (Phase-9 upload widget, form-scoped session). - Client pre-validation uses only the public-safe validation subset
(
minLength/maxLength/min/max/maxFiles/options); thepatternsource is never sent to the client. The server re-validates everything — client checks are UX only (rule 19/20: the frontend is never trusted). - Honeypot — two hidden inputs (
website,hp) rendered visually hidden (a real bot fills them; a human doesn't see them). A filled honeypot → the API answers 400 generically and logsform.honeypot_triggered. - Rate limit — 5/min per form+IP (server-side); a 429 surfaces the
Retry-Afterguidance. - Submit → on 201 the success state (config
successMessageor the default "Thanks! Your response was recorded."). On 422,error.details[]renders underform-submit-field-errorsnext to the offending fields.
4. The responses view (TASK-0109)
components/form/form-responses-view.tsx (the FORM view's second tab).
- Windowed list, no virtualization dependency: rows have a deterministic
height (desktop 48 px, mobile 180 px cards), so a fixed-height container
(
form-responses-scroll, bounded withmax-h) keeps the DOM to < 30 rows while the dataset can be hundreds ("Load more" appends the next cursor page; the server caps at 100 per request). Verified by the E2E (40 seeded rows → < 30 in the DOM before and after scrolling). - Row content: submitter ("Anonymous" for null; "Former member" for a
deleted user), the answers in question order (each "label: value", deleted
questions as "Deleted question"), the IP (displayed as the server masks it
per role — full for OWNER/ADMIN,
1.2.***.***for members, with anipMaskedlock hint), the received time, and a link to the created record. - Delete — per-row trash →
ConfirmDialog→DELETE …?confirm=true. The server cascades the record + its page + the linked files (+ storage purge) and audits it. The row leaves the list on success. - Mobile (≤ 767 px) — the table becomes fixed-height answer cards (one per submission, answers stacked); the delete flow is identical.
- Export — the button is disabled with a tooltip: the API answers
501 NOT_IMPLEMENTED_YET(Phase 13) — the contract is documented in ../api/forms.md §5.3 so Phase 13 implements against it.
5. Accessibility
- Every public input carries
aria-label= the question label (required questions are marked; the field errors list is a plain<ul>). - The tab bar uses
aria-current="page"on the active tab. - The rating input is a group of labelled buttons (
"<n> of <max>"). - The delete confirm is a focus-trapped dialog with a destructive button.