loopable / web /src /customer-grid /FormInterface.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
20.1 kB
// ---------------------------------------------------------------------------
// 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<FieldType> = new Set<FieldType>([
"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<LinkState>(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<FormSpec>) => {
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 (
<div className="cg-form-empty">
A form belongs to a saved view. Save this view first, then build the form on it.
</div>
);
}
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 (
<div className="cg-form-empty">
This database does not accept new records, so a form has nowhere to put an answer.
</div>
);
}
const diverged = spec === null && (link.stored?.fields ?? 0) > 0;
return (
<div className="cg-form-build">
<div className="cg-form-pane">
<h2 className="cg-form-h">Form</h2>
{!canEdit && readOnlyReason && <p className="cg-form-note">{readOnlyReason}</p>}
{/* ⚠ 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 && (
<p className="cg-form-warn">
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.
</p>
)}
{/* ⚠ 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 && (
<p className="cg-form-warn">
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.
</p>
)}
<label className="cg-form-row">
<span>Title</span>
<input
type="text"
value={spec?.title ?? ""}
maxLength={120}
disabled={!canEdit}
placeholder="What this form is for"
onChange={(e) => patch({ title: e.target.value })}
/>
</label>
<label className="cg-form-row">
<span>Description</span>
<textarea
rows={2}
value={spec?.desc ?? ""}
maxLength={1000}
disabled={!canEdit}
placeholder="Anything the person filling it in should know"
onChange={(e) => patch({ desc: e.target.value })}
/>
</label>
<label className="cg-form-row">
<span>Submit button</span>
<input
type="text"
value={spec?.submitLabel ?? ""}
maxLength={60}
disabled={!canEdit}
placeholder="Submit"
onChange={(e) => patch({ submitLabel: e.target.value })}
/>
</label>
<h3 className="cg-form-sub">Questions</h3>
{askable.length === 0 ? (
<p className="cg-form-note">
No column on this database can be a form question yet. A form can ask for text,
numbers, dates, choices and yes/no — it cannot ask for a value the app computes.
</p>
) : (
<ul className="cg-form-fields">
{askable.map((f) => {
const on = chosen.includes(f.key);
return (
<li key={f.key} className={on ? "cg-form-field on" : "cg-form-field"}>
<label className="cg-form-pick">
<input
type="checkbox"
checked={on}
disabled={!canEdit}
onChange={() =>
patch({
// Order is the FORM's, so a newly ticked question joins the END rather
// than being sorted into the column order — the same rule the server
// preserves and the public page renders by.
fields: on
? chosen.filter((k) => k !== f.key)
: [...chosen, f.key],
required: on
? (spec?.required ?? []).filter((k) => k !== f.key)
: spec?.required,
})
}
/>
<span className="cg-form-label">{f.label}</span>
<span className="cg-form-type">{TYPE_LABELS[f.type]}</span>
</label>
{on && (
<label className="cg-form-req">
<input
type="checkbox"
checked={required.has(f.key)}
disabled={!canEdit}
onChange={() =>
patch({
required: required.has(f.key)
? (spec?.required ?? []).filter((k) => k !== f.key)
: [...(spec?.required ?? []), f.key],
})
}
/>
<span>Required</span>
</label>
)}
</li>
);
})}
</ul>
)}
</div>
<div className="cg-form-pane">
<h3 className="cg-form-sub">Who can submit</h3>
<label className="cg-form-radio">
<input
type="radio"
name="cg-form-access"
checked={!restricted}
disabled={!canEdit}
onChange={() => patch({ access: "public" })}
/>
<span>
<strong>Anyone with the link</strong>
<em>No sign-in. Rate-limited, and capped per day.</em>
</span>
</label>
<label className="cg-form-radio">
<input
type="radio"
name="cg-form-access"
checked={restricted}
disabled={!canEdit}
onChange={() => patch({ access: "emails" })}
/>
<span>
<strong>Only the people I list</strong>
{/* ⚠ The honest boundary, in the UI and not only in the docstring: this asks the
submitter to name themselves and refuses an address that is not on the list. It
does not PROVE the address is theirs. */}
<em>
They enter their email address and it must match the list. This identifies them; it
does not verify them — anyone with the link who knows a listed address can submit.
</em>
</span>
</label>
{restricted && (
<div className="cg-form-emails">
<ul>
{emails.map((e) => (
<li key={e}>
<span>{e}</span>
{canEdit && (
<button
type="button"
onClick={() => patch({ emails: emails.filter((x) => x !== e) })}
aria-label={`Remove ${e}`}
>
Remove
</button>
)}
</li>
))}
{emails.length === 0 && (
<li className="cg-form-note">
Nobody is listed yet, so nobody can submit this form.
</li>
)}
</ul>
{canEdit && (
<form
className="cg-form-addmail"
onSubmit={(e) => {
e.preventDefault();
const next = emailDraft.trim().toLowerCase();
if (!next || emails.includes(next)) return;
patch({ emails: [...emails, next] });
setEmailDraft("");
}}
>
<input
type="email"
value={emailDraft}
placeholder="name@company.com"
onChange={(e) => setEmailDraft(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)}
</div>
)}
<h3 className="cg-form-sub">Share link</h3>
{problem && <p className="cg-form-warn">{problem}</p>}
{link.url ? (
<div className="cg-form-link">
<code>{absolute(link.url)}</code>
<div className="cg-form-actions">
<button type="button" onClick={copy}>
{copied ? "Copied" : "Copy link"}
</button>
{canEdit && (
<>
<button
type="button"
disabled={busy}
onClick={() => void call("POST", { topic, view: viewId, regenerate: true })}
title="Mints a new link. The old one stops working immediately."
>
Regenerate
</button>
<button type="button" disabled={busy} onClick={() => void call("DELETE")}>
Turn off
</button>
</>
)}
</div>
</div>
) : (
<div className="cg-form-link">
<p className="cg-form-note">
This form has no link yet, so nobody outside the app can reach it.
</p>
{canEdit && (
<button
type="button"
disabled={busy || chosen.length === 0}
onClick={() => void call("POST", { topic, view: viewId })}
>
Create link
</button>
)}
{chosen.length === 0 && (
<p className="cg-form-note">Pick at least one question first.</p>
)}
</div>
)}
{chosen.length > 0 && (
<p className="cg-form-note">
Answers land as new records in this database
{chosen.length === 1 ? ", one column" : `, ${chosen.length} columns`}:{" "}
{chosen.map((k) => byKey.get(k)?.label ?? k).join(", ")}.
</p>
)}
</div>
</div>
);
}
export default FormInterface;