RowEditor
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
- tcmts.cli.data_pages.RowField
- tcmts.react.navigate
- tcmts.react.refresh
- tcmts.share.toasts
- tcmts.studio.removeRow
- tcmts.studio.saveRow