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/Owner.tsx
// The owner area: /f/<id>/results (summary), /f/<id>/responses, /f/<id>/edit and /f/<id>/share. Opening any of them
// with #k=<admin key> makes this browser an owner (the admin link works on any device).
import { useEffect, useMemo, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { keyFor, remember } from "./lib/mine";
import { navigate } from "./lib/router";
import { toast, copy, confirmAction, Menu } from "./ui";
import { Missing, Failed } from "./Respond";
import { EditForm, createForm } from "./Builder";
import { Summary, Responses } from "./Results";
import { Share } from "./Share";
import { SITE_URL } from "../functions/lib/config";
import { parseQuestions, type FormDoc, type ResponseDoc } from "../functions/lib/forms";

export type Tab = "results" | "responses" | "edit" | "share";

// Reads and live updates return at most 1,000 rows. Below that the live set is complete;
// past it, each change reloads every response page by page.
const PAGE = 1000;

function useResponses(formId: string): ResponseDoc[] | undefined {
  const path = `forms/${formId}/responses`;
  const { data: live } = useQuery(path, { limit: PAGE }) as { data: ResponseDoc[] | undefined };
  const [all, setAll] = useState<ResponseDoc[]>();
  useEffect(() => {
    if (!live) return;
    if (live.length < PAGE) { setAll(live); return; }
    let cancelled = false;
    (async () => {
      const rows: ResponseDoc[] = [];
      let cursor: string | undefined;
      do {
        const res = (await get(path, { limit: PAGE, ...(cursor ? { cursor } : {}) })) as { data: ResponseDoc[]; nextCursor?: string | null };
        rows.push(...res.data);
        cursor = res.nextCursor || undefined;
      } while (cursor);
      if (!cancelled) setAll(rows);
    })().catch((e) => {
      if (cancelled) return;
      toast(`Showing the latest ${PAGE.toLocaleString()} responses. ${explain(e)}`);
      setAll(live);
    });
    return () => { cancelled = true; };
  }, [path, live]);
  return all;
}

function OwnerSkeleton() {
  return (
    <div className="owner" aria-busy="true" aria-label="Opening your form">
      <header className="owner-head">
        <div className="wrap owner-head-inner">
          <div className="owner-title-block">
            <span className="skeleton skeleton-text" style={{ width: 110 }} />
            <span className="skeleton" style={{ height: 40, width: "min(460px, 78vw)", marginTop: 10 }} />
          </div>
        </div>
        <div className="wrap owner-tabs"><span className="skeleton skeleton-text" style={{ width: 260, margin: "16px 0" }} /></div>
      </header>
      <div className="wrap owner-body"><span className="skeleton" style={{ height: 180, maxWidth: 760, borderRadius: 18 }} /></div>
    </div>
  );
}

export function Owner({ formId, tab }: { formId: string; tab: Tab }) {
  const [state, setState] = useState<"checking" | "ready" | "denied" | "badkey" | "missing" | "error">("checking");
  const [error, setError] = useState("");
  // Pasting the admin link into a tab already on this page only changes the hash: check again.
  const [attempt, setAttempt] = useState(0);
  useEffect(() => {
    const onHash = () => { if (/(^|[#&])k=/.test(location.hash)) { setState("checking"); setAttempt((n) => n + 1); } };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);
  useEffect(() => {
    let cancelled = false;
    const done = (s: typeof state) => { if (!cancelled) setState(s); };
    (async () => {
      try {
        const uid = await me();
        const linkKey = new URLSearchParams(location.hash.slice(1)).get("k");
        // The key never stays in the address bar; this browser keeps it in "Your forms".
        if (linkKey) history.replaceState(null, "", location.pathname + location.search);
        if (!(await get(`forms/${formId}/joins/${uid}`))) {
          try { await set(`forms/${formId}/joins/${uid}`, { at: serverTimestamp() }); } catch { return done("missing"); }
        }
        const form = (await get(`forms/${formId}`)) as FormDoc | null;
        if (!form) return done("missing");
        const key = linkKey || keyFor(formId);
        let owner = Boolean(await get(`forms/${formId}/owners/${uid}`));
        if (!owner && key) {
          try {
            await setMany([
              { path: `formclaims/${formId}/c/${uid}`, document: { key } },
              { path: `forms/${formId}/owners/${uid}`, document: { at: serverTimestamp() } },
            ]);
            owner = true;
          } catch {
            return done(linkKey ? "badkey" : "denied");
          }
        }
        if (owner && key) remember(formId, key, form.title);
        done(owner ? "ready" : "denied");
      } catch (err) {
        if (!cancelled) { setError(explain(err)); setState("error"); }
      }
    })();
    return () => { cancelled = true; };
  }, [formId, attempt]);

  if (state === "checking") return <OwnerSkeleton />;
  if (state === "missing") return <Missing />;
  if (state === "error") return <Failed message={error} retry={() => { setState("checking"); setAttempt((n) => n + 1); }} />;
  if (state === "denied" || state === "badkey") return (
    <div className="wrap page-pad narrow">
      <div className="empty-state enter">
        <h1>{state === "badkey" ? "That admin link is not right" : "Only the form's owner can see this"}</h1>
        <p>
          {state === "badkey"
            ? "The key at the end of the link does not match this form. Copy the whole admin link again."
            : "To see responses or edit this form on this device, open the admin link you saved when the form was made."}
        </p>
        <a className="btn" href={`/f/${formId}`} data-nav>Open the form</a>
      </div>
    </div>
  );
  return <OwnerView formId={formId} tab={tab} />;
}

function OwnerView({ formId, tab }: { formId: string; tab: Tab }) {
  const { data: form } = useQuery(`forms/${formId}`) as { data: FormDoc | null | undefined };
  const rows = useResponses(formId);
  const questions = useMemo(() => parseQuestions(form?.questions), [form?.questions]);
  const [busy, setBusy] = useState(false);

  useEffect(() => { if (form?.title) document.title = `${form.title} | Forms`; }, [form?.title]);

  if (form === undefined) return <OwnerSkeleton />;
  if (!form) return <Missing />;

  const toggleClosed = async () => {
    if (!form.closed && !(await confirmAction({
      title: "Close this form?",
      body: "People who open the link will see that it is closed and cannot send responses. You can reopen it any time.",
      action: "Close form",
    }))) return;
    setBusy(true);
    try {
      await set(`forms/${formId}`, { closed: !form.closed });
      toast(form.closed ? "Form reopened. It takes responses again." : "Form closed. It no longer takes responses.");
    } catch (e) {
      toast(explain(e));
    }
    setBusy(false);
  };

  const duplicate = async () => {
    setBusy(true);
    try {
      const id = await createForm({ title: `${form.title} (copy)`.slice(0, 200), description: form.description || "", thankYou: form.thankYou || "", questions });
      toast("Copy made. It has no responses yet.");
      navigate(`/f/${id}/edit`);
    } catch (e) {
      toast(explain(e));
      setBusy(false);
    }
  };

  const deleteAll = async () => {
    const list = rows || [];
    if (!list.length || !(await confirmAction({
      title: "Delete all responses?",
      body: `This deletes ${list.length === 1 ? "the only response" : `all ${list.length.toLocaleString()} responses`} to this form, for every owner. It cannot be undone. Download a CSV first if you might need them.`,
      action: "Delete all",
      danger: true,
    }))) return;
    setBusy(true);
    if (list.length > 50) toast(`Deleting ${list.length.toLocaleString()} responses. Keep this page open.`);
    try {
      for (let i = 0; i < list.length; i += 50) {
        await setMany(list.slice(i, i + 50).map((r) => ({ path: `forms/${formId}/responses/${r.id}`, document: null })));
      }
      toast(`Deleted ${list.length.toLocaleString()} ${list.length === 1 ? "response" : "responses"}`);
    } catch (e) {
      toast(explain(e));
    }
    setBusy(false);
  };

  const count = rows?.length;
  const tabs: [Tab, string, number?][] = [["results", "Summary"], ["responses", "Responses", count], ["edit", "Edit"], ["share", "Share"]];
  return (
    <div className="owner">
      <header className="owner-head">
        <div className="wrap owner-head-inner">
          <div className="owner-title-block">
            <p className="eyebrow">Your form · <span className={form.closed ? "status-closed" : "status-open"}>{form.closed ? "Closed" : "Open"}</span></p>
            <h1 className="owner-title">{form.title}</h1>
          </div>
          <div className="row owner-actions">
            <a className="btn btn-line" href={`/f/${formId}`} target="_blank" rel="noopener">
              View form
              <svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 5H5v10h10v-3M11 4h5v5M16 4l-7 7" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </a>
            <Menu label="More actions">
              <button type="button" role="menuitem" onClick={() => copy(`${SITE_URL}/f/${formId}`, "Form link copied")}>Copy form link</button>
              <button type="button" role="menuitem" onClick={toggleClosed} disabled={busy}>{form.closed ? "Reopen form" : "Close form"}</button>
              <button type="button" role="menuitem" onClick={duplicate} disabled={busy}>Duplicate form</button>
              <hr />
              <button type="button" role="menuitem" className="danger" onClick={deleteAll} disabled={busy || !count}>Delete all responses</button>
            </Menu>
          </div>
        </div>
        <nav className="wrap owner-tabs" aria-label="Form">
          {tabs.map(([t, label, n]) => (
            <a key={t} href={`/f/${formId}/${t}`} data-nav aria-current={t === tab ? "page" : undefined}>
              {label}{n !== undefined && <span className="tab-count">{n.toLocaleString()}</span>}
            </a>
          ))}
        </nav>
      </header>
      <div className="wrap owner-body enter" key={tab}>
        {form.closed && tab !== "edit" && (
          <p className="notice closed-note">This form is closed: people see that it no longer takes responses. <button className="link-btn" type="button" onClick={toggleClosed} disabled={busy}>Reopen it</button></p>
        )}
        {tab === "results" && <Summary formId={formId} form={form} questions={questions} rows={rows} />}
        {tab === "responses" && <Responses formId={formId} form={form} questions={questions} rows={rows} />}
        {tab === "edit" && (
          <EditForm key={formId} formId={formId}
            initial={{ title: form.title, description: form.description || "", thankYou: form.thankYou || "", questions }} />
        )}
        {tab === "share" && <Share formId={formId} form={form} questions={questions} />}
      </div>
    </div>
  );
}

Change history

Quality pass: conditional questions, 0 to 10 scale, QR code, prefill links, results tabs, API docs
ce30390934 · 5 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