RowEditor

tcmts.studio.RowEditor · #x7fqopbu · term · runs in the browser

uses useState

A row of a table, to edit: a form, a field each (text, a number, a checkbox, a date and time, one of its values, or JSON for what's nested), or the whole row as JSON (dates as ISO strings, maps as [key, value] pairs, sets as arrays). Saved (Save, or ⌘S) as the table's own writes are, checked against its row type (saveRow), the reason shown under it if it doesn't fit; or deleted, asking first (removeRow). A new row (id empty) starts from what its type needs. An optional field left empty is left out; a field that may be null, null. back is the page without the row.

import type { RowField } from "#codebase/tcmts/cli/data_pages";
import { navigate, refresh } from "#codebase/tcmts/react";
import { toasts } from "#codebase/tcmts/share";
import { removeRow, saveRow } from "#codebase/tcmts/studio";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { json } from "@codemirror/lang-json";
import { bracketMatching, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorState, Prec } from "@codemirror/state";
import { drawSelection, EditorView, keymap, lineNumbers } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { useEffect, useRef, useState } from "react";
import {
  Button, Checkbox, Dialog, DialogTrigger, Heading, Input, Label, ListBox, ListBoxItem, Popover, Select, SelectValue,
  TextArea, TextField,
} from "react-aria-components";

export function RowEditor({
  table,
  id,
  initial,
  fields,
  back,
  rowHref,
}: {
  table: string;
  id: string;
  initial: string;
  fields: RowField[];
  back: string;
  rowHref: string;
}) {
  const start = (() => {
    try {
      return JSON.parse(initial) as Record<string, unknown>;
    } catch {
      return {};
    }
  })();
  // (each field as the form has it: text, except a checkbox's)
  const asText = (f: RowField, v: unknown): string | boolean =>
    f.input === "boolean"
      ? v === true
      : v === undefined || v === null
        ? ""
        : f.input === "date" && typeof v === "string"
          ? v.slice(0, 16)
          : f.input === "json" || f.input === "choice"
            ? JSON.stringify(v, null, f.input === "json" ? 2 : undefined)
            : String(v);
  const [mode, setMode] = useState<"form" | "json">(
    fields.length && start && typeof start === "object" ? "form" : "json",
  );
  const [form, setForm] = useState<Record<string, string | boolean>>(() =>
    Object.fromEntries(fields.map((f) => [f.name, asText(f, start[f.name])])),
  );
  const [text, setText] = useState(initial);
  const host = useRef<HTMLDivElement>(null);
  const view = useRef<EditorView | undefined>(undefined);
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  // the row the form says (fields it doesn't have kept as they are)
  const fromForm = (): Record<string, unknown> => {
    const out: Record<string, unknown> = { ...start };
    for (const f of fields) {
      const v = form[f.name];
      if (f.input === "boolean") {
        out[f.name] = v === true;
        continue;
      }
      const s = String(v ?? "");
      if (!s.trim() && f.input !== "text") {
        if (f.optional) delete out[f.name];
        else if (f.nullable) out[f.name] = null;
        else out[f.name] = f.input === "number" ? Number.NaN : s;
        continue;
      }
      if (!s && f.input === "text" && f.optional) {
        delete out[f.name];
        continue;
      }
      out[f.name] =
        f.input === "number"
          ? Number(s)
          : f.input === "date"
            ? Number.isNaN(Date.parse(s))
              ? s
              : new Date(s.length === 16 ? `${s}:00Z` : s).toISOString()
            : f.input === "json" || f.input === "choice"
              ? (() => {
                  try {
                    return JSON.parse(s);
                  } catch {
                    throw new Error(`${f.name} isn't JSON`);
                  }
                })()
              : s;
    }
    return out;
  };
  const current = () =>
    mode === "form"
      ? `${JSON.stringify(fromForm(), null, 2)}\n`
      : (view.current?.state.doc.toString() ?? text);
  const save = async () => {
    setBusy(true);
    setError("");
    try {
      const saved = await saveRow(table, id, current());
      toasts.add(
        { title: id ? `Saved ${id}` : `Added ${saved} to ${table}`, tone: "ok" },
        { timeout: 4000 },
      );
      if (id) refresh();
      else
        navigate(`${rowHref}${rowHref.includes("?") ? "&" : "?"}row=${encodeURIComponent(saved)}`);
    } catch (e) {
      setError((e as Error).message);
    }
    setBusy(false);
  };
  const toJsonMode = () => {
    try {
      setText(current());
      setError("");
      setMode("json");
    } catch (e) {
      setError((e as Error).message);
    }
  };
  const toForm = () => {
    try {
      const v = JSON.parse(view.current?.state.doc.toString() ?? text) as Record<string, unknown>;
      Object.assign(start, v);
      setForm(Object.fromEntries(fields.map((f) => [f.name, asText(f, v[f.name])])));
      setError("");
      setMode("form");
    } catch (e) {
      setError(`That isn't JSON: ${(e as Error).message}`);
    }
  };
  useEffect(() => {
    if (mode !== "json" || !host.current) return;
    const style = HighlightStyle.define([
      { tag: [tags.string, tags.special(tags.string)], color: "var(--s)" },
      { tag: [tags.number, tags.bool, tags.null], color: "var(--n)" },
      { tag: tags.propertyName, color: "var(--k)" },
    ]);
    const v = new EditorView({
      parent: host.current,
      state: EditorState.create({
        doc: text,
        extensions: [
          lineNumbers(),
          history(),
          drawSelection(),
          bracketMatching(),
          closeBrackets(),
          json(),
          syntaxHighlighting(style),
          Prec.high(keymap.of([{ key: "Mod-s", run: () => (void save(), true) }])),
          keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, indentWithTab]),
        ],
      }),
    });
    view.current = v;
    return () => {
      setText(v.state.doc.toString());
      view.current = undefined;
      v.destroy();
    };
  }, [mode, table, id]);
  const set = (name: string) => (v: string | boolean) => setForm((f) => ({ ...f, [name]: v }));
  return (
    <form
      className="roweditor"
      onSubmit={(e) => {
        e.preventDefault();
        void save();
      }}
      onKeyDown={(e) => {
        if (e.key === "s" && (e.metaKey || e.ctrlKey) && mode === "form") {
          e.preventDefault();
          void save();
        }
      }}
    >
      {mode === "form" ? (
        <div className="rowform">
          {fields.map((f) => (
            <div className="rowfield" key={f.name}>
              {f.input === "boolean" ? (
                <>
                  <span className="rowlabel mono">{f.name}</span>
                  <Checkbox
                    className="check"
                    isSelected={form[f.name] === true}
                    onChange={set(f.name)}
                  >
                    <span className="box-mark" aria-hidden="true" />
                    {form[f.name] === true ? "true" : "false"}
                  </Checkbox>
                </>
              ) : f.input === "choice" ? (
                <>
                  <span className="rowlabel mono">{f.name}</span>
                  <Select
                    aria-label={f.name}
                    selectedKey={String(form[f.name] ?? "")}
                    onSelectionChange={(k) => set(f.name)(String(k))}
                  >
                    <Button className="btn small mono">
                      <SelectValue />
                    </Button>
                    <Popover className="menu-pop">
                      <ListBox className="menu">
                        {[
                          ...(f.optional || f.nullable ? [""] : []),
                          ...(f.choices ?? []).map((c) => JSON.stringify(c)),
                        ].map((c) => (
                          <ListBoxItem key={c} id={c} className="opt mono">
                            {c || (f.optional ? "(left out)" : "null")}
                          </ListBoxItem>
                        ))}
                      </ListBox>
                    </Popover>
                  </Select>
                </>
              ) : (
                <TextField
                  className="rowtext"
                  value={String(form[f.name] ?? "")}
                  onChange={set(f.name)}
                  type={
                    f.input === "number" ? "number" : f.input === "date" ? "datetime-local" : "text"
                  }
                >
                  <Label className="rowlabel mono">{f.name}</Label>
                  {f.input === "json" ? (
                    <TextArea
                      className="field mono"
                      rows={Math.min(8, Math.max(2, String(form[f.name] ?? "").split("\n").length))}
                    />
                  ) : (
                    <Input
                      className="field mono"
                      {...(f.input === "number" ? { step: "any" } : {})}
                    />
                  )}
                </TextField>
              )}
              <span className="muted mono rowtype">
                {f.type}
                {f.input === "date" ? " (UTC)" : ""}
                {f.optional ? ", may be left out" : f.nullable ? ", may be null" : ""}
              </span>
            </div>
          ))}
        </div>
      ) : (
        <div className="cmbox rowcm">
          <div ref={host} />
        </div>
      )}
      {error && <pre className="code errmsg roweditor-error">{error}</pre>}
      <div className="actions">
        <Button className="btn small primary" type="submit" isPending={busy}>
          {busy ? "Saving…" : id ? "Save" : "Add it"}
        </Button>
        <a className="btn small" href={back}>
          {id ? "Close" : "Cancel"}
        </a>
        {fields.length > 0 && (
          <Button className="linkbtn" onPress={mode === "form" ? toJsonMode : toForm}>
            {mode === "form" ? "Edit as JSON" : "Edit as a form"}
          </Button>
        )}
        {id && (
          <DialogTrigger>
            <Button className="btn small danger right">Delete</Button>
            <Popover className="menu-pop getit" placement="bottom end" offset={6}>
              <Dialog className="getit-body" aria-label={`Delete ${id}`}>
                {({ close }) => (
                  <>
                    <Heading slot="title" className="getit-title">
                      Delete {id} from {table}?
                    </Heading>
                    <p>It's gone from the app's data on this machine, for good.</p>
                    <div className="actions">
                      <Button className="btn" onPress={close}>
                        Keep it
                      </Button>
                      <Button
                        className="btn danger"
                        onPress={async () => {
                          close();
                          try {
                            await removeRow(table, id);
                            toasts.add({ title: `Deleted ${id}`, tone: "ok" }, { timeout: 4000 });
                            navigate(back);
                          } catch (e) {
                            toasts.add({
                              title: `Couldn't delete ${id}`,
                              description: (e as Error).message,
                              tone: "bad",
                            });
                          }
                        }}
                      >
                        Delete
                      </Button>
                    </div>
                  </>
                )}
              </Dialog>
            </Popover>
          </DialogTrigger>
        )}
      </div>
    </form>
  );
}

uses

used by