| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const FORM_FIELD_TYPES: ReadonlySet<FieldType> = new Set<FieldType>([ |
| "text", "select", "multiselect", "int", "currency", "pct", "date", |
| "checkbox", "phone", "email", "url", "rating", |
| ]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export type { FormSpec } from "./types"; |
|
|
| export interface FormInterfaceProps { |
| |
| topic: string; |
| |
| viewId: string | null; |
| |
| fields: Field[]; |
| |
| |
| |
| |
| |
| |
| spec: FormSpec | null; |
| |
| onSpec: (next: FormSpec | null) => void; |
| |
| canEdit: boolean; |
| |
| readOnlyReason: string | null; |
| |
| 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 }; |
|
|
| |
| |
| 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(""); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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] |
| ); |
|
|
| |
| 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]); |
|
|
| |
| 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) { |
| |
| |
| |
| 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; |
|
|