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/Builder.tsx
// The form builder: used to create a form at /new and to edit one at /f/<id>/edit.
import { useEffect, useRef, useState } from "react";
import { set, setMany, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate, guardLeave } from "./lib/router";
import { remember, keyFor } from "./lib/mine";
import { toast } from "./ui";
import {
checkFormText, checkQuestions, conditionValues, randomId, QUESTION_TYPES, OPTION_TYPES, CONDITION_TYPES, type Question, type QuestionType,
} from "../functions/lib/forms";
export interface Draft { title: string; description: string; thankYou: string; questions: Question[] }
const blankQuestion = (type: QuestionType = "short_text"): Question => ({
id: randomId(8), type, label: "", required: false, ...(OPTION_TYPES.includes(type) ? { options: ["", ""] } : {}),
});
/** Earlier questions a condition can use: ones whose answer is an option or yes or no. */
const conditionSources = (questions: Question[], i: number) =>
questions.slice(0, i).filter((x) => CONDITION_TYPES.includes(x.type) && conditionValues(x).some((o) => o.trim()));
/** Why a question's condition cannot be saved, or null when it is fine. */
function conditionProblem(questions: Question[], i: number): string | null {
const c = questions[i].showIf;
if (!c) return null;
const source = conditionSources(questions, i).find((x) => x.id === c.question);
if (!source) return "Pick an earlier multiple choice, checkboxes, dropdown or yes or no question.";
if (!conditionValues(source).includes(c.is)) return "Pick one of its answers.";
return null;
}
/** Checks a draft; returns what to store or an error sentence. */
function prepare(d: Draft) {
const text = checkFormText(d);
if ("error" in text) return { error: text.error };
const broken = d.questions.findIndex((_, i) => conditionProblem(d.questions, i));
if (broken >= 0) return { error: `Question ${broken + 1}: fix its condition or remove it.` };
const checked = checkQuestions(d.questions);
if ("error" in checked) return { error: checked.error };
return { doc: { ...text, questions: JSON.stringify(checked.questions) } };
}
/** Creates a form owned by this browser and remembers its admin key. Throws an error sentence. */
export async function createForm(d: Draft): Promise<string> {
const ready = prepare(d);
if ("error" in ready) throw new Error(ready.error);
const uid = await me();
const formId = randomId(20);
const key = randomId(24);
await setMany([
{ path: `forms/${formId}`, document: { ...ready.doc, closed: false, createdBy: uid, createdAt: serverTimestamp() } },
{ path: `forms/${formId}/joins/${uid}`, document: { at: serverTimestamp() } },
{ path: `forms/${formId}/owners/${uid}`, document: { at: serverTimestamp() } },
{ path: `formkeys/${formId}`, document: { key } },
]);
remember(formId, key, ready.doc.title);
return formId;
}
export function NewForm() {
useEffect(() => { document.title = "New form | Forms"; }, []);
const save = async (d: Draft) => {
const formId = await createForm(d);
localStorage.setItem(`forms:new:${formId}`, "1");
guardLeave(null);
navigate(`/f/${formId}/share`);
};
return (
<div className="wrap page-pad builder-page">
<p className="eyebrow">New form</p>
<h1 className="h1 builder-h1">Build your form</h1>
<Builder initial={{ title: "", description: "", thankYou: "", questions: [blankQuestion()] }} onSave={save} saveLabel="Create form" />
</div>
);
}
export function EditForm({ formId, initial }: { formId: string; initial: Draft }) {
const save = async (d: Draft) => {
const ready = prepare(d);
if ("error" in ready) throw new Error(ready.error);
await set(`forms/${formId}`, ready.doc);
const key = keyFor(formId);
if (key) remember(formId, key, ready.doc.title);
toast("Saved. People see the change right away.");
};
return <Builder initial={initial} onSave={save} saveLabel="Save changes" editing />;
}
function Builder({ initial, onSave, saveLabel, editing }: { initial: Draft; onSave: (d: Draft) => Promise<void>; saveLabel: string; editing?: boolean }) {
const [draft, setDraft] = useState<Draft>(initial);
const [saved, setSaved] = useState(() => JSON.stringify(initial));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const dirty = JSON.stringify(draft) !== saved;
useEffect(() => {
guardLeave(dirty ? "You have changes that are not saved. Leave anyway?" : null);
return () => guardLeave(null);
}, [dirty]);
// A save error describes the draft it was about; any edit clears it.
useEffect(() => setError(null), [draft]);
const patch = (p: Partial<Draft>) => setDraft((d) => ({ ...d, ...p }));
const setQ = (id: string, p: Partial<Question>) => setDraft((d) => ({ ...d, questions: d.questions.map((q) => (q.id === id ? { ...q, ...p } : q)) }));
// Renaming an option keeps the conditions that use it pointing at it.
const renameOption = (id: string, i: number, value: string) => setDraft((d) => {
const old = d.questions.find((q) => q.id === id)?.options?.[i];
return {
...d,
questions: d.questions.map((q) => q.id === id ? { ...q, options: q.options?.map((o, j) => (j === i ? value : o)) }
: q.showIf?.question === id && q.showIf.is === old ? { ...q, showIf: { question: id, is: value } } : q),
};
});
const move = (i: number, by: number) => setDraft((d) => {
const qs = [...d.questions];
const [q] = qs.splice(i, 1);
qs.splice(i + by, 0, q);
return { ...d, questions: qs };
});
const add = (type: QuestionType) => {
const q = blankQuestion(type);
setDraft((d) => ({ ...d, questions: [...d.questions, q] }));
setFocusId(q.id);
};
const duplicate = (i: number) => {
const q = { ...draft.questions[i], id: randomId(8), options: draft.questions[i].options && [...draft.questions[i].options!] };
setDraft((d) => ({ ...d, questions: [...d.questions.slice(0, i + 1), q, ...d.questions.slice(i + 1)] }));
setFocusId(q.id);
};
const remove = (i: number) => setDraft((d) => ({ ...d, questions: d.questions.filter((_, j) => j !== i) }));
const save = async () => {
setBusy(true);
setError(null);
try {
await onSave(draft);
setSaved(JSON.stringify(draft));
} catch (e) {
setError(explain(e));
}
setBusy(false);
};
return (
<form className="builder" onSubmit={(e) => { e.preventDefault(); save(); }} noValidate>
<section className="card builder-head">
<textarea className="title-input" value={draft.title} onChange={(e) => patch({ title: e.target.value.replace(/\n/g, " ") })} maxLength={200} rows={1}
placeholder="Form title" aria-label="Form title" autoFocus={!editing} ref={fitHeight} onInput={(e) => fitHeight(e.currentTarget)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.currentTarget.nextElementSibling as HTMLElement | null)?.focus(); } }} />
<textarea className="textarea desc-input" value={draft.description} onChange={(e) => patch({ description: e.target.value })} maxLength={2000}
placeholder="Description (optional): what this form is for" aria-label="Description" rows={2} />
</section>
<ol className="q-list">
{draft.questions.map((q, i) => (
<li key={q.id}>
<QuestionEditor q={q} index={i} count={draft.questions.length} autoFocus={focusId === q.id}
sources={conditionSources(draft.questions, i)} problem={conditionProblem(draft.questions, i)}
numberOf={(id) => draft.questions.findIndex((x) => x.id === id) + 1}
onChange={(p) => setQ(q.id, p)} onRename={(j, v) => renameOption(q.id, j, v)}
onMove={(by) => move(i, by)} onDuplicate={() => duplicate(i)} onRemove={() => remove(i)} />
</li>
))}
</ol>
{!draft.questions.length && <p className="notice">No questions yet. A form with no questions still works as a website form endpoint: it keeps whatever fields your site sends.</p>}
<section className="add-q">
<p className="label">Add a question</p>
<div className="type-grid">
{(Object.keys(QUESTION_TYPES) as QuestionType[]).map((t) => (
<button key={t} type="button" className="type-btn press" onClick={() => add(t)}>
<TypeIcon type={t} />{QUESTION_TYPES[t]}
</button>
))}
</div>
</section>
<section className="card builder-thanks">
<label className="field">
<span>Message after someone submits</span>
<textarea className="textarea" value={draft.thankYou} onChange={(e) => patch({ thankYou: e.target.value })} maxLength={1000} rows={2}
placeholder="Your response was sent." />
</label>
</section>
<div className="save-bar">
<div className="save-bar-inner">
<span className={error ? "save-error" : "muted small"} role={error ? "alert" : undefined}>
{error || (editing ? (dirty ? "Unsaved changes" : "All changes saved") : `${draft.questions.length} question${draft.questions.length === 1 ? "" : "s"}`)}
</span>
<button className="btn" type="submit" disabled={editing && !dirty && !busy} aria-busy={busy}><span>{saveLabel}</span></button>
</div>
</div>
</form>
);
}
/** Grows a one-line title box so a long title wraps instead of scrolling out of sight. */
const fitHeight = (el: HTMLTextAreaElement | null) => {
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
};
const X_ICON = <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>;
function QuestionEditor({ q, index, count, autoFocus, sources, problem, numberOf, onChange, onRename, onMove, onDuplicate, onRemove }: {
q: Question; index: number; count: number; autoFocus: boolean; sources: Question[]; problem: string | null; numberOf: (id: string) => number;
onChange: (p: Partial<Question>) => void; onRename: (i: number, v: string) => void; onMove: (by: number) => void; onDuplicate: () => void; onRemove: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!autoFocus) return;
ref.current?.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
ref.current?.querySelector<HTMLInputElement>(".q-label-input")?.focus({ preventScroll: true });
}, [autoFocus]);
const hasOptions = OPTION_TYPES.includes(q.type);
const options = q.options ?? [];
const setType = (type: QuestionType) => {
const needs = OPTION_TYPES.includes(type);
onChange({ type, options: needs ? (options.length ? options : ["", ""]) : undefined });
};
const addOption = (focus = true) => {
onChange({ options: [...options, ""] });
if (focus) setTimeout(() => [...(ref.current?.querySelectorAll<HTMLInputElement>(".opt-row input") ?? [])].pop()?.focus(), 0);
};
const source = sources.find((x) => x.id === q.showIf?.question);
const addCondition = () => {
const first = sources[sources.length - 1];
onChange({ showIf: { question: first.id, is: conditionValues(first).find((o) => o.trim()) ?? "" } });
};
const n = index + 1;
return (
<div className="card q-card" ref={ref}>
<div className="q-top">
<span className="q-num" aria-hidden="true">{n}</span>
<input className="input q-label-input" value={q.label} onChange={(e) => onChange({ label: e.target.value })} maxLength={300}
placeholder="Question" aria-label={`Question ${n}`} />
<select className="select q-type" value={q.type} onChange={(e) => setType(e.target.value as QuestionType)} aria-label={`Question ${n} type`}>
{(Object.keys(QUESTION_TYPES) as QuestionType[]).map((t) => <option key={t} value={t}>{QUESTION_TYPES[t]}</option>)}
</select>
</div>
<input className="input input-quiet" value={q.help ?? ""} onChange={(e) => onChange({ help: e.target.value || undefined })} maxLength={500}
placeholder="Help text (optional)" aria-label={`Question ${n} help text`} />
{hasOptions && (
<div className="opts">
{options.map((o, i) => (
<div className="opt-row" key={i}>
<span className={`opt-mark mark-${q.type}`} aria-hidden="true">{q.type === "dropdown" ? `${i + 1}.` : ""}</span>
<input className="input input-sm grow" value={o} onChange={(e) => onRename(i, e.target.value)} maxLength={200}
placeholder={`Option ${i + 1}`} aria-label={`Question ${n}, option ${i + 1}`} enterKeyHint="next"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (i === options.length - 1) addOption(); else (e.currentTarget.parentElement?.nextElementSibling?.querySelector("input") as HTMLInputElement | null)?.focus(); } }} />
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove option ${i + 1}`} onClick={() => onChange({ options: options.filter((_, j) => j !== i) })}>{X_ICON}</button>
</div>
))}
{options.length < 50 && <button className="btn btn-sm btn-quiet add-opt" type="button" onClick={() => addOption()}>+ Add option</button>}
</div>
)}
{q.type === "rating" && <p className="q-preview">People pick 1 to 5 stars.</p>}
{q.type === "scale" && <p className="q-preview">People pick a number from 0 to 10. Results show the average and the Net Promoter Score.</p>}
{q.type === "yes_no" && <p className="q-preview">People answer Yes or No.</p>}
{q.showIf && (
<div className="cond enter">
<span className="cond-label cond-if">Show only if</span>
<select className="select input-sm cond-q" value={source ? source.id : ""} aria-label={`Question ${n}: show only if this question`}
onChange={(e) => { const s = sources.find((x) => x.id === e.target.value); if (s) onChange({ showIf: { question: s.id, is: conditionValues(s).find((o) => o.trim()) ?? "" } }); }}>
{!source && <option value="">Pick a question</option>}
{sources.map((s) => <option key={s.id} value={s.id}>{`${numberOf(s.id)}. ${s.label.trim() || "Untitled question"}`}</option>)}
</select>
<span className="cond-label cond-is">is</span>
<select className="select input-sm cond-v" value={source && conditionValues(source).includes(q.showIf.is) ? q.showIf.is : ""} aria-label={`Question ${n}: show only if the answer is`}
onChange={(e) => onChange({ showIf: { question: q.showIf!.question, is: e.target.value } })}>
{!(source && conditionValues(source).includes(q.showIf.is)) && <option value="">Pick an answer</option>}
{source && conditionValues(source).filter((o) => o.trim()).map((o) => <option key={o} value={o}>{source.type === "yes_no" ? (o === "yes" ? "Yes" : "No") : o}</option>)}
</select>
<button className="btn btn-quiet btn-icon btn-sm cond-x" type="button" aria-label={`Question ${n}: remove the condition`} title="Remove the condition" onClick={() => onChange({ showIf: undefined })}>{X_ICON}</button>
{problem && <p className="field-error cond-error">{problem}</p>}
</div>
)}
<div className="q-foot">
<label className="switch">
<input type="checkbox" checked={Boolean(q.required)} onChange={(e) => onChange({ required: e.target.checked })} />
<span className="switch-track" aria-hidden="true" />
Required
</label>
<span className="q-tools">
{!q.showIf && sources.length > 0 && (
<button className="btn btn-quiet btn-sm cond-add" type="button" onClick={addCondition} aria-label={`Question ${n}: add a condition`} title="Show this question only for some answers">
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5v5.5a3 3 0 0 0 3 3h7M12 9l3 3-3 3M5 16.5V13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span className="cond-add-text">Condition</span>
</button>
)}
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => onMove(-1)} disabled={index === 0} aria-label={`Move question ${n} up`} title="Move up">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15V5M5.5 9.5 10 5l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => onMove(1)} disabled={index === count - 1} aria-label={`Move question ${n} down`} title="Move down">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 5v10M5.5 10.5 10 15l4.5-4.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={onDuplicate} aria-label={`Duplicate question ${n}`} title="Duplicate">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><rect x="7" y="7" width="9" height="9" rx="2" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M4 13V5.5A1.5 1.5 0 0 1 5.5 4H13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
</button>
<button className="btn btn-quiet btn-icon btn-sm icon-danger" type="button" onClick={onRemove} aria-label={`Delete question ${n}`} title="Delete">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M4.5 6h11M8 6V4.5h4V6M6 6l.7 9.5h6.6L14 6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</span>
</div>
</div>
);
}
function TypeIcon({ type }: { type: QuestionType }) {
const p = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
const icons: Record<QuestionType, JSX.Element> = {
short_text: <path d="M4 10h12" {...p} />,
long_text: <path d="M4 6h12M4 10h12M4 14h8" {...p} />,
email: <><rect x="3" y="5" width="14" height="10" rx="2" {...p} /><path d="m4 6 6 5 6-5" {...p} /></>,
number: <path d="M8 4 6.5 16M13.5 4 12 16M4.5 8h12M3.5 12h12" {...p} />,
date: <><rect x="3.5" y="5" width="13" height="11" rx="2" {...p} /><path d="M3.5 8.5h13M7 3.5v3M13 3.5v3" {...p} /></>,
choice: <><circle cx="10" cy="10" r="6" {...p} /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></>,
checkboxes: <><rect x="4" y="4" width="12" height="12" rx="2.5" {...p} /><path d="m7 10 2 2 4-4.5" {...p} /></>,
dropdown: <><rect x="3" y="6" width="14" height="8" rx="2" {...p} /><path d="m11.5 9 1.5 1.5L14.5 9" {...p} /></>,
rating: <path d="m10 3.8 1.9 3.9 4.3.6-3.1 3 .7 4.3-3.8-2-3.8 2 .7-4.3-3.1-3 4.3-.6Z" {...p} />,
scale: <><path d="M3 13.5h14" {...p} /><path d="M4 13.5v-2M7.5 13.5V10M11 13.5V8.5M14.5 13.5V6.5" {...p} /></>,
yes_no: <><path d="m4 10.5 2.5 2.5L11 8" {...p} /><path d="m13 8 4 4M17 8l-4 4" {...p} /></>,
};
return <svg className="type-icon" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true">{icons[type]}</svg>;
}
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