Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone cf614aedcf4cdabfa9c45095
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Respond.tsx
// The public form at /f/<id>: what respondents see. No account: the page reads the form from the public API
// and sends the response there, so it shows fast on a phone and nobody signs in.
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { callApi, ApiError } from "./lib/api";
import { keyFor } from "./lib/mine";
import { checkAnswer, checkAnswers, isShown, matchKeys, type Question } from "../functions/lib/forms";
interface PublicForm { id: string; title: string; description?: string; thankYou?: string; closed: boolean; questions: Question[] }
type Raw = Record<string, string | string[]>;
const filled = (v: string | string[] | undefined) => (Array.isArray(v) ? v.length > 0 : Boolean(v?.trim()));
const reduceMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
export function PageSkeleton() {
return (
<div className="wrap page-pad" aria-busy="true" aria-label="Loading">
<span className="skeleton skeleton-text" style={{ width: 120 }} />
<span className="skeleton" style={{ height: 44, width: "min(520px, 80%)", marginTop: 14 }} />
<span className="skeleton" style={{ height: 160, marginTop: 32, borderRadius: 18 }} />
</div>
);
}
function FormSkeleton() {
return (
<div className="respond" aria-busy="true" aria-label="Loading the form">
<div className="card respond-head">
<span className="skeleton" style={{ height: 40, width: "72%" }} />
<span className="skeleton skeleton-text" style={{ width: "90%" }} />
</div>
{[0, 1, 2].map((i) => (
<div className="card q-answer" key={i}>
<span className="skeleton skeleton-text" style={{ width: "46%" }} />
<span className="skeleton" style={{ height: 46 }} />
</div>
))}
</div>
);
}
export function Missing() {
return (
<div className="wrap page-pad narrow">
<div className="empty-state enter">
<img className="art" src="/art/card-forms.webp" alt="" width="180" height="180" />
<h1>This form does not exist</h1>
<p>Check the link you were sent. Form links are long and every character counts.</p>
<a className="btn" href="/" data-nav>Make your own form</a>
</div>
</div>
);
}
export function Failed({ message, retry }: { message: string; retry: () => void }) {
return (
<div className="wrap page-pad narrow">
<div className="error-state enter" role="alert">
<h1>That did not load</h1>
<p>{message}</p>
<button className="btn" type="button" onClick={retry}>Try again</button>
</div>
</div>
);
}
export function Respond({ formId }: { formId: string }) {
const [form, setForm] = useState<PublicForm | null>();
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setError(null);
callApi<PublicForm>(`/forms/${formId}`)
.then((f) => { if (!cancelled) { setForm(f); document.title = `${f.title} | Forms`; } })
.catch((e) => { if (!cancelled) e instanceof ApiError && e.status === 404 ? setForm(null) : setError((e as Error).message); });
return () => { cancelled = true; };
}, [formId, attempt]);
if (error) return <Failed message={error} retry={() => setAttempt((n) => n + 1)} />;
if (form === undefined) return <FormSkeleton />;
if (!form) return <Missing />;
return <RespondView formId={formId} form={form} />;
}
/** Answers from the link (?<question label or id>=<answer>), so a person or an assistant can send a link with some answers filled in. */
function prefill(questions: Question[]): Raw {
const params = new URLSearchParams(location.search);
const input: Record<string, string | string[]> = {};
for (const key of new Set(params.keys())) {
const all = params.getAll(key);
input[key] = all.length > 1 ? all : all[0];
}
const { raw } = matchKeys(questions, input);
const out: Raw = {};
for (const q of questions) {
const { value } = checkAnswer(q, raw[q.id]);
if (value !== undefined) out[q.id] = Array.isArray(value) ? value : String(value);
}
return out;
}
function RespondView({ formId, form }: { formId: string; form: PublicForm }) {
const questions = form.questions;
const draftKey = `forms:draft:${formId}`;
const [raw, setRaw] = useState<Raw>(() => {
let draft: Raw = {};
try { draft = JSON.parse(sessionStorage.getItem(draftKey) || "{}"); } catch { /* none */ }
return { ...prefill(questions), ...draft };
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [tried, setTried] = useState(false);
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<string | null>(null);
const [done, setDone] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const owned = Boolean(keyFor(formId));
// A half-filled form survives a reload or a phone dropping the tab. Kept in this tab only.
useEffect(() => {
try { sessionStorage.setItem(draftKey, JSON.stringify(raw)); } catch { /* storage off */ }
}, [draftKey, raw]);
// Questions in order, leaving out those the earlier answers hide.
const visible = useMemo(() => {
const seen: Raw = {};
return questions.filter((q) => {
if (!isShown(q, seen)) return false;
if (raw[q.id] !== undefined) seen[q.id] = raw[q.id];
return true;
});
}, [questions, raw]);
const answered = visible.filter((q) => filled(raw[q.id])).length;
// Count only questions that still show: changing an answer can hide one that had an error.
const errorCount = visible.filter((q) => errors[q.id]).length;
const change = (q: Question, v: string | string[]) => {
setRaw((r) => ({ ...r, [q.id]: v }));
if (tried) setErrors((e) => {
const { error } = checkAnswer(q, v);
const { [q.id]: _, ...rest } = e;
return error ? { ...rest, [q.id]: error } : rest;
});
};
const focusCard = (card: HTMLElement | null | undefined) => {
card?.scrollIntoView({ block: "center", behavior: reduceMotion() ? "auto" : "smooth" });
card?.querySelector<HTMLElement>("input, textarea, select")?.focus({ preventScroll: true });
};
const submit = async () => {
setTried(true);
setFailure(null);
const checked = checkAnswers(questions, raw);
setErrors(checked.errors);
if (Object.keys(checked.errors).length) {
const first = questions.find((q) => checked.errors[q.id]);
focusCard(first && formRef.current?.querySelector<HTMLElement>(`[data-q="${first.id}"]`));
if (checked.errors._form) setFailure(checked.errors._form);
return;
}
setBusy(true);
try {
// No content-type header: the browser sends text/plain, which skips the CORS preflight round trip. The API reads the JSON either way.
await callApi(`/forms/${formId}/responses`, { method: "POST", body: JSON.stringify({ answers: checked.answers }) });
try { sessionStorage.removeItem(draftKey); } catch { /* storage off */ }
setDone(true);
window.scrollTo({ top: 0 });
} catch (e) {
const err = e as ApiError;
setFailure(err.status === 403 ? "This form just closed, so your response was not sent." : err.message);
}
setBusy(false);
};
// Enter in a one-line answer moves to the next question, and sends the form from the last one.
const onKeyDown = (e: KeyboardEvent<HTMLFormElement>) => {
const t = e.target as HTMLElement;
if (e.key !== "Enter" || t.tagName !== "INPUT" || e.nativeEvent.isComposing) return;
e.preventDefault();
const cards = [...(formRef.current?.querySelectorAll<HTMLElement>("[data-q]") ?? [])];
const next = cards[cards.findIndex((c) => c.contains(t)) + 1];
if (next) focusCard(next);
else submit();
};
const ownerBar = owned && (
<div className="owner-bar">
<span>This is your form.</span>
<span className="row"><a href={`/f/${formId}/results`} data-nav>Results</a><a href={`/f/${formId}/edit`} data-nav>Edit</a></span>
</div>
);
if (done) return (
<div className="respond">
{ownerBar}
<div className="card respond-done enter">
<svg className="done-check" width="48" height="48" viewBox="0 0 44 44" aria-hidden="true"><circle cx="22" cy="22" r="21" fill="#e8f1ea" /><path d="m14 22.5 5.5 5.5L30.5 16" fill="none" stroke="#2f5a3c" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
<h1 className="respond-title">Thank you</h1>
<p className="respond-desc">{form.thankYou || "Your response was sent."}</p>
<button className="btn btn-line" type="button" onClick={() => { setRaw({}); setErrors({}); setTried(false); setDone(false); }}>Send another response</button>
</div>
<MadeWith />
</div>
);
const open = !form.closed && questions.length > 0;
return (
<div className="respond">
{open && (
<div className="progress" role="progressbar" aria-label="Questions answered" aria-valuemin={0} aria-valuemax={visible.length} aria-valuenow={answered}>
<span style={{ transform: `scaleX(${visible.length ? answered / visible.length : 0})` }} />
</div>
)}
{ownerBar}
<header className="card respond-head">
<h1 className="respond-title">{form.title}</h1>
{form.description && <p className="respond-desc">{form.description}</p>}
{open && visible.some((q) => q.required) && <p className="hint"><span className="req">*</span> Required</p>}
</header>
{form.closed ? (
<div className="empty-state">
<h2>This form is closed</h2>
<p>It no longer takes responses.</p>
</div>
) : !questions.length ? (
<div className="empty-state">
<h2>This form takes submissions from a website</h2>
<p>It has no questions to fill in here.</p>
</div>
) : (
<form ref={formRef} className="respond-form" onSubmit={(e) => { e.preventDefault(); submit(); }} onKeyDown={onKeyDown} noValidate>
{visible.map((q, i) => (
<fieldset key={q.id} className={`card q-answer${errors[q.id] ? " has-error" : ""}${q.showIf ? " enter" : ""}`} data-q={q.id}
aria-describedby={errors[q.id] ? `e-${q.id}` : undefined}>
<legend className="q-label" id={`l-${q.id}`}>{q.label}{q.required && <span className="req" aria-hidden="true"> *</span>}{q.required && <span className="sr-only"> (required)</span>}</legend>
{q.help && <p className="q-help" id={`h-${q.id}`}>{q.help}</p>}
<AnswerInput q={q} value={raw[q.id]} invalid={Boolean(errors[q.id])} last={i === visible.length - 1} onChange={(v) => change(q, v)} />
{errors[q.id] && <p className="field-error" id={`e-${q.id}`}>{errors[q.id]}</p>}
</fieldset>
))}
{failure && <p className="notice notice-error" role="alert">{failure}</p>}
<div className="respond-submit">
<button className="btn btn-lg" type="submit" aria-busy={busy}><span>Submit</span></button>
<span className="muted small" aria-live="polite">
{tried && errorCount > 0
? <span className="field-error">{errorCount === 1 ? "One answer needs a look." : `${errorCount} answers need a look.`}</span>
: `${answered} of ${visible.length} answered`}
</span>
</div>
</form>
)}
<MadeWith />
</div>
);
}
function MadeWith() {
return <p className="made-with">Made with <a href="/">Forms</a>, free from OpenApps. Never send passwords in a form.</p>;
}
function Clear({ show, onClear }: { show: boolean; onClear: () => void }) {
return show ? <button className="btn btn-quiet btn-sm clear-answer" type="button" onClick={onClear}>Clear answer</button> : null;
}
function AnswerInput({ q, value, invalid, last, onChange }: {
q: Question; value: string | string[] | undefined; invalid: boolean; last: boolean; onChange: (v: string | string[]) => void;
}) {
const text = typeof value === "string" ? value : "";
const field = {
"aria-labelledby": `l-${q.id}`,
"aria-describedby": q.help ? `h-${q.id}` : undefined,
"aria-invalid": invalid || undefined,
"aria-required": q.required || undefined,
enterKeyHint: last ? "send" as const : "next" as const,
};
const clearable = !q.required && Boolean(text);
switch (q.type) {
case "short_text":
return <input className="input" value={text} onChange={(e) => onChange(e.target.value)} maxLength={1000} placeholder="Your answer" {...field} />;
case "long_text":
return <textarea className="textarea" value={text} onChange={(e) => onChange(e.target.value)} maxLength={10000} rows={4} placeholder="Your answer" {...field} enterKeyHint={undefined} />;
case "email":
return <input className="input" type="email" inputMode="email" autoComplete="email" autoCapitalize="off" spellCheck={false} value={text} onChange={(e) => onChange(e.target.value)} maxLength={254} placeholder="name@example.com" {...field} />;
case "number":
return <input className="input input-num" inputMode="decimal" value={text} onChange={(e) => onChange(e.target.value)} placeholder="0" {...field} />;
case "date":
return <input className="input input-date" type="date" value={text} onChange={(e) => onChange(e.target.value)} {...field} />;
case "dropdown":
return (
<select className="select" value={text} onChange={(e) => onChange(e.target.value)} {...field} enterKeyHint={undefined}>
<option value="">Choose</option>
{(q.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
case "choice":
return (
<div className="choices">
{(q.options || []).map((o) => (
<label key={o} className={`choice press${text === o ? " on" : ""}`}>
<input type="radio" name={q.id} checked={text === o} onChange={() => onChange(o)} aria-invalid={invalid || undefined} />
<span>{o}</span>
</label>
))}
<Clear show={clearable} onClear={() => onChange("")} />
</div>
);
case "checkboxes": {
const list = Array.isArray(value) ? value : [];
return (
<div className="choices">
{(q.options || []).map((o) => (
<label key={o} className={`choice press${list.includes(o) ? " on" : ""}`}>
<input type="checkbox" checked={list.includes(o)} onChange={(e) => onChange(e.target.checked ? [...list, o] : list.filter((x) => x !== o))} aria-invalid={invalid || undefined} />
<span>{o}</span>
</label>
))}
</div>
);
}
case "rating":
case "scale": {
const nums = q.type === "rating" ? ["1", "2", "3", "4", "5"] : Array.from({ length: 11 }, (_, n) => String(n));
return (
<div>
<div className={q.type === "rating" ? "rating" : "scale"} role="radiogroup" aria-labelledby={`l-${q.id}`} aria-describedby={q.help ? `h-${q.id}` : undefined}>
{nums.map((n) => (
<label key={n} className={`tile press${text === n ? " picked" : ""}${q.type === "rating" && text && Number(text) >= Number(n) ? " on" : ""}`}>
<input type="radio" className="tile-input" name={q.id} value={n} checked={text === n} onChange={() => onChange(n)}
aria-label={q.type === "rating" ? `${n} out of 5` : `${n} out of 10`} />
{q.type === "rating" && <svg width="24" height="24" viewBox="0 0 20 20" aria-hidden="true"><path d="m10 2.6 2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L2.8 7.9l5-.7Z" /></svg>}
<span aria-hidden="true">{n}</span>
</label>
))}
</div>
<Clear show={clearable} onClear={() => onChange("")} />
</div>
);
}
case "yes_no":
return (
<div>
<div className="yesno" role="radiogroup" aria-labelledby={`l-${q.id}`}>
{[["yes", "Yes"], ["no", "No"]].map(([v, l]) => (
<label key={v} className={`tile press${text === v ? " picked" : ""}`}>
<input type="radio" className="tile-input" name={q.id} value={v} checked={text === v} onChange={() => onChange(v)} />
<span>{l}</span>
</label>
))}
</div>
<Clear show={clearable} onClear={() => onChange("")} />
</div>
);
}
}
Change history
Quality pass: conditional questions, 0 to 10 scale, QR code, prefill links, results tabs, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync