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/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
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