forms-free.openapps.xyz

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.
Owned app · rev ce303909347f
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.
← All files
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
ce30390934 · 4 hours ago
Bounded source sync
7d82add8ae · 1 day ago
Bounded source sync
8230fd7d59 · 1 day ago
Bounded source sync
d92a6e3b44 · 1 day ago
Bounded source sync
b548641922 · 1 day ago
Bounded source sync
b89074ffd2 · 1 day ago