// --------------------------------------------------------------------------- // customer-grid / FormInterface.tsx // ⭐⭐ WAVE-29 R7 (owner item 10, contracts C3 / C4) — THE FORM INTERFACE. // // The owner's ask, verbatim: "a user fills in information; the form has a shareable link that // accepts submissions either from authorized people (by email) or from the public. Google Forms // is the reference." // // This file is the BUILDER — the in-app surface a `form`-kind view renders. The page a stranger // fills in is `FormPublic.tsx` and it already shipped in wave 23, along with the whole public // door (`api/routes_forms.py`). What had never existed was any way to CREATE the thing they // serve: `form` was not a legal display mode and `_clean_display` dropped `display.form`, so the // key the public door reads could not be written by anything. That is D-90, and it is why this // component is mostly plumbing rather than a new idea — the door was built first and the handle // never fitted. // // THE TWO HALVES, and they are stored in two different places on purpose: // * the SPEC (questions, title, who may submit) lives on the view, at `config.display.form`, // written through the host's ordinary view-save path like every other mode's configuration; // * the TOKEN lives in a server-owned index a browser cannot write, because a share token that // rides the wire is a token a browser can CHOOSE, and the public resolver answers with the // first tenant that matches. See `routes_forms.py`'s note for the whole argument. // --------------------------------------------------------------------------- import { useCallback, useEffect, useMemo, useState } from "react"; import { API_V1, CREDENTIALS } from "../apiContract"; import type { Field, FieldType, FormSpec } from "./types"; import { TYPE_LABELS } from "./iconShapes"; import "./FormInterface.css"; /** * The field types a form may COLLECT. **Mirrors `platform/aios_grid.py::FORM_FIELD_TYPES` and * `verify_forms.py` compares the two files name-for-name** — a type offered here and refused * there is a question that silently vanishes from the published form, which is the failure a * one-sided list produces every time. * * An ALLOW-LIST, not a list of exclusions: a type missing here is a question the builder cannot * ask yet, while a type wrongly present is a public door writing values its column cannot mean. * The two mistakes do not cost the same, so the list fails closed. */ export const FORM_FIELD_TYPES: ReadonlySet = new Set([ "text", "select", "multiselect", "int", "currency", "pct", "date", "checkbox", "phone", "email", "url", "rating", ]); /** * The stored bag at `view.config.display.form` is `FormSpec`, and it is DECLARED IN `./types` * (imported above), not here. ⛔ Declaring it in this file made a type-only circular import — * `types.ts` needs it for `DisplaySpec.form` while this file needs `Field` from `types.ts` — which * vite erased and `tsc --ignoreConfig` could not, killing `verify_optimism` and * `verify_live_workspace` with TS6142. Re-exported here so this file still names the contract it * edits; the single declaration stays in the type module. ⛔ No `token` — see the header. */ export type { FormSpec } from "./types"; export interface FormInterfaceProps { /** The database this form collects into (`topicForScope(scope)`), e.g. `ut_leads`. */ topic: string; /** The view holding the spec. `null` while no saved view is active — a form has nowhere to live. */ viewId: string | null; /** Every column of the table; this component decides which can be questions. */ fields: Field[]; /** * The stored spec, or `null` when this view has no form yet. * ⚠ REQUIRED and NULLABLE, never optional: the host passes `displaySpec?.form ?? null`, and the * `?? null` is the point — an optional prop degrades to "the feature does not exist", which is * indistinguishable from never having been built. */ spec: FormSpec | null; /** Writes the spec back into `config.display.form`. `null` clears it. */ onSpec: (next: FormSpec | null) => void; /** May this viewer change the form's configuration (the view-config grant)? */ canEdit: boolean; /** Why not, in one sentence, when `canEdit` is false. `null` when it is true. */ readOnlyReason: string | null; /** Does this database accept new records at all? A locked one can never be a form's target. */ canCollect: boolean; } interface LinkState { token: string | null; url: string | null; stored: { fields: number; access: string; title: string } | null; } const EMPTY_LINK: LinkState = { token: null, url: null, stored: null }; /** The share URL the page shows. The server returns an origin-qualified one when it knows the * public base; otherwise it returns a path, and only the browser knows where it is. */ function absolute(url: string): string { return url.startsWith("http") ? url : `${window.location.origin}${url}`; } export function FormInterface({ topic, viewId, fields, spec, onSpec, canEdit, readOnlyReason, canCollect, }: FormInterfaceProps) { const [link, setLink] = useState(EMPTY_LINK); const [busy, setBusy] = useState(false); const [problem, setProblem] = useState(""); const [copied, setCopied] = useState(false); const [emailDraft, setEmailDraft] = useState(""); /** * The columns that can be questions. * * ⚠ The server enforces this too, and both walls are needed rather than one: a column can * BECOME computed (a formula, a rollup, a metric binding) long after it was added to a live * form, and this component is not running when that happens. Here it is a courtesy that keeps * the builder honest; `routes_forms._public_form` is the wall. */ const askable = useMemo( () => fields.filter( (f) => FORM_FIELD_TYPES.has(f.type) && f.source !== "odoo" && !f.automation && !f.rollup && !f.metric ), [fields] ); const byKey = useMemo(() => new Map(fields.map((f) => [f.key, f])), [fields]); const chosen = spec?.fields ?? []; const required = useMemo(() => new Set(spec?.required ?? []), [spec]); const emails = spec?.emails ?? []; const restricted = spec?.access === "emails"; /** * ⭐ THE DEAD-STATE DETECTOR, and it earns its four lines. * * The spec reaches this panel through the host's `cleanDisplay`, which builds its output key by * key — so a host that does not carry `display.form` hands back `null` forever. The panel would * then look exactly like a form nobody has built yet: every keystroke accepted, nothing stored, * no error anywhere. That is [W-07]'s failure verbatim — "the feature does not exist" and "the * feature was never wired" are indistinguishable from the outside, and four consecutive waves * lost a hand-off to it. * * One write plus one render answers it: if this panel has SENT a spec and the next render still * has none, the carry is missing. It cannot false-positive on a slow save — `onSpec` is * synchronous host state, not a request. */ const [wrote, setWrote] = useState(false); const carryBroken = wrote && spec === null; const patch = useCallback( (next: Partial) => { if (!canEdit) return; setWrote(true); onSpec({ ...(spec ?? {}), ...next }); }, [canEdit, onSpec, spec] ); // ---- the share link, read from the server (never minted on read) ------------------------- const url = `${API_V1}/form-link?topic=${encodeURIComponent(topic)}&view=${encodeURIComponent( viewId ?? "" )}`; useEffect(() => { if (!viewId) { setLink(EMPTY_LINK); return; } let live = true; fetch(url, { credentials: CREDENTIALS }) .then((r) => (r.ok ? r.json() : EMPTY_LINK)) .then((body: LinkState) => { if (live) setLink(body ?? EMPTY_LINK); }) .catch(() => { if (live) setLink(EMPTY_LINK); }); return () => { live = false; }; }, [url, viewId]); const call = useCallback( async (method: "POST" | "DELETE", body?: unknown) => { setBusy(true); setProblem(""); try { const response = await fetch(method === "POST" ? `${API_V1}/form-link` : url, { method, credentials: CREDENTIALS, ...(body ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}), }); const payload = await response.json().catch(() => null); if (!response.ok) { setProblem( (payload as { error?: { message?: string } } | null)?.error?.message ?? `The server answered ${response.status}.` ); return; } setLink( method === "DELETE" ? { ...EMPTY_LINK, stored: link.stored } : { ...(payload as LinkState), stored: link.stored } ); } finally { setBusy(false); } }, [link.stored, url] ); const copy = useCallback(() => { if (!link.url) return; void navigator.clipboard?.writeText(absolute(link.url)); setCopied(true); window.setTimeout(() => setCopied(false), 1600); }, [link.url]); // ---- the states that are not a form ------------------------------------------------------- if (!viewId) { return (
A form belongs to a saved view. Save this view first, then build the form on it.
); } if (!canCollect) { // ⚠ NOT `readOnlyReason`, and the two must not be merged: this is a fact about the DATABASE // (a locked one accepts no new records, so a form could only ever collect refusals), while // `readOnlyReason` is about the VIEWER's grant. return (
This database does not accept new records, so a form has nowhere to put an answer.
); } const diverged = spec === null && (link.stored?.fields ?? 0) > 0; return (

Form

{!canEdit && readOnlyReason &&

{readOnlyReason}

} {/* ⚠ The divergence banner. The `spec` prop comes from the host's `cleanDisplay`, and if that ever stops carrying `display.form` the panel would render an empty form over a stored one — and the next save would erase real questions. Saying so beats losing them quietly. */} {diverged && (

The server holds {link.stored?.fields} question {link.stored?.fields === 1 ? "" : "s"} for this view that this page is not carrying. Reload before editing — saving now would replace them.

)} {/* ⚠ The banner above needs a STORED spec to compare against, so it can only speak once a form has been saved successfully at least once. This one covers the state before that: nothing has ever been stored, so there is nothing to diverge from. */} {carryBroken && (

Nothing is being saved: this view gave back no form after the last change. Reload the page — if it repeats, this build cannot store forms and an administrator should be told, because everything typed here is being discarded.

)}