| // --------------------------------------------------------------------------- | |
| // customer-grid / JsonViewer.tsx — ⭐ wave-23 C7 (owner item 5 / ruling R5). | |
| // | |
| // THE BIG VIEWER a json cell opens on click. One component, reached from two | |
| // places (the grid cell's click path and the record drawer's value slot), for | |
| // the reason `assetUrl` is one resolver: two readers of the same document that | |
| // disagree about how it folds is a bug nobody can see in either one alone. | |
| // | |
| // The document is a STRING at rest (the scalar Row contract — see the `json` | |
| // note in types.ts), so everything here is text in and text out. Nothing in | |
| // this file ever writes a parsed OBJECT back. | |
| // | |
| // THREE decisions worth stating, because each has a plausible alternative: | |
| // | |
| // 1. THE RAW TAB IS THE EDITOR, and there is no field-by-field one. A form | |
| // over a document whose shape nobody declared would have to invent that | |
| // shape from the current value — so adding a key would mean editing the | |
| // form's idea of the schema, and the first machine write with a different | |
| // shape would silently drop what it did not recognise. | |
| // 2. PARSE ON SAVE, REFUSE WITH A SENTENCE. Never repair, never re-indent on | |
| // the way in: `{a:1}` is not JSON and quietly "fixing" it to `{"a":1}` | |
| // teaches a person their typo was fine, until the day a value cannot be | |
| // guessed. The host applies the same rule at the write wall (C7), so a | |
| // refusal here says the same thing the server would. | |
| // 3. THE TREE COLLAPSES BY DEPTH, not by hand-kept state per node beyond what | |
| // the reader opens. A 200-key payload that arrives fully expanded is a | |
| // scroll bar, not a document. | |
| // | |
| // ⭐ WAVE-27 item 13 (owner ruling R13) AMENDS decision 3: THE TREE IS EDITABLE | |
| // NOW, two-way synced with the raw tab, and the raw text WINS on conflict. | |
| // | |
| // "Wins" is structural rather than a rule anybody has to enforce: `draft` (the | |
| // raw text) is the single source of truth, `parsed` is derived from it, and the | |
| // tree is a projection of `parsed`. A tree edit writes a new VALUE at a path, | |
| // re-serialises the whole document and sets `draft` — so there is never a second | |
| // copy of the document to reconcile, and a draft that stops parsing simply has | |
| // no tree to show (the raw tab is where it gets read, exactly as before). | |
| // | |
| // ⛔ WHAT THE TREE EDITS, AND WHAT IT DELIBERATELY DOES NOT. Leaf VALUES, in | |
| // place, keeping each leaf's own type. NOT keys, NOT structure, NOT a value's | |
| // type — those stay on the raw tab, and that is decision 1 above still standing: | |
| // a form that can add a key has to have an opinion about the document's shape, | |
| // and nobody declared one. Editing `"12"` into `12` is a shape change, so it is | |
| // a raw edit; editing `"Anna"` into `"Anne"` is a value change, so it is here. | |
| // --------------------------------------------------------------------------- | |
| import { useEffect, useId, useMemo, useRef, useState } from "react"; | |
| import type { ReactNode } from "react"; | |
| import { BodyPortal, useOverlayLayer } from "./OverlaySurface"; | |
| import { dayText, jsonParse, jsonPretty, MAX_JSON_BYTES, postsWindowOf } from "./display"; | |
| import type { PostSummary, PostsWindow } from "./display"; | |
| /** Bytes, not characters — the host's cap is on the stored UTF-8, and a | |
| * document of 20,000 emoji is 80 KB. Counting `length` would let a value | |
| * through here that the server then refuses, which is the one refusal a user | |
| * cannot act on (nothing on screen said it was too big). */ | |
| export function jsonByteLength(text: string): number { | |
| return typeof TextEncoder === "undefined" | |
| ? text.length | |
| : new TextEncoder().encode(text).length; | |
| } | |
| /** The one-line summary in the modal's header: what this document IS. */ | |
| export function jsonSummary(text: string): string { | |
| const parsed = jsonParse(text); | |
| const bytes = jsonByteLength(text); | |
| const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`; | |
| if (String(text ?? "").trim() === "") return "Empty"; | |
| if (!parsed.ok) return `Not valid JSON · ${size}`; | |
| const v = parsed.value; | |
| if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? "" : "s"} · ${size}`; | |
| if (v !== null && typeof v === "object") { | |
| const n = Object.keys(v as object).length; | |
| return `${n} key${n === 1 ? "" : "s"} · ${size}`; | |
| } | |
| return `${v === null ? "null" : typeof v} · ${size}`; | |
| } | |
| // --- the tree --------------------------------------------------------------- | |
| /** How deep a node is open when the viewer mounts. Two levels shows the shape | |
| * of every payload this product actually receives (an object of objects) and | |
| * stops before the leaf spam of a scraped array. */ | |
| const OPEN_TO_DEPTH = 2; | |
| function isBranch(v: unknown): v is Record<string, unknown> | unknown[] { | |
| return v !== null && typeof v === "object"; | |
| } | |
| /** The value's own text, in its own ink class. Strings keep their quotes: a | |
| * bare `12` and a `"12"` are different documents and the tree is where that | |
| * difference has to be visible. */ | |
| function Leaf({ v }: { v: unknown }): ReactNode { | |
| if (v === null) return <span className="cg-json-null">null</span>; | |
| if (typeof v === "string") return <span className="cg-json-str">"{v}"</span>; | |
| if (typeof v === "number") return <span className="cg-json-num">{String(v)}</span>; | |
| if (typeof v === "boolean") return <span className="cg-json-bool">{String(v)}</span>; | |
| return <span className="cg-json-str">{String(v)}</span>; | |
| } | |
| /** | |
| * ⭐ R13 — the path a tree edit names, and the ONE function that applies it. | |
| * | |
| * Structural clone down the path only: every container on the way is copied, everything else is | |
| * shared. That keeps a 32 KB document's edit cheap AND keeps the old value untouched, which is | |
| * what makes the raw tab's Revert honest. | |
| * | |
| * ⚠ Returns the ORIGINAL root unchanged if the path does not resolve — a stale path (the raw tab | |
| * changed the shape under an open tree) must not invent the containers it names. The caller reads | |
| * that as "nothing to do" rather than silently growing the document a key it never had. | |
| */ | |
| function setAtPath(root: unknown, path: (string | number)[], next: unknown): unknown { | |
| if (path.length === 0) return next; | |
| const [head, ...rest] = path; | |
| if (Array.isArray(root)) { | |
| const i = typeof head === "number" ? head : Number(head); | |
| if (!Number.isInteger(i) || i < 0 || i >= root.length) return root; | |
| const copy = root.slice(); | |
| copy[i] = setAtPath(root[i], rest, next); | |
| return copy; | |
| } | |
| if (root !== null && typeof root === "object") { | |
| const obj = root as Record<string, unknown>; | |
| const k = String(head); | |
| if (!Object.prototype.hasOwnProperty.call(obj, k)) return root; | |
| return { ...obj, [k]: setAtPath(obj[k], rest, next) }; | |
| } | |
| return root; | |
| } | |
| /** | |
| * R13 — the in-place leaf editor. | |
| * | |
| * TYPE-PRESERVING by construction, which is the whole reason it is not one "edit as JSON" box: | |
| * a string edits as text (no quotes to type), a number as a number, a boolean as its two values. | |
| * A leaf therefore cannot change TYPE here — that is a shape change and belongs to the raw tab | |
| * (see the amendment at the top of the file). | |
| * | |
| * ⛔ A number that does not parse is REFUSED, not repaired and not coerced to a string. This is | |
| * decision 2 of the header applied one level down: quietly turning `12a` into `"12a"` teaches a | |
| * person their typo was fine, right up until a consumer of the document expects arithmetic. | |
| */ | |
| function LeafEditor({ | |
| value, | |
| onCommit, | |
| onCancel, | |
| }: { | |
| value: string | number | boolean; | |
| onCommit: (next: string | number | boolean) => void; | |
| onCancel: () => void; | |
| }) { | |
| const kind = typeof value; | |
| const [text, setText] = useState(() => String(value)); | |
| const [bad, setBad] = useState(false); | |
| const commit = () => { | |
| if (kind === "number") { | |
| const n = Number(text.trim()); | |
| if (text.trim() === "" || !Number.isFinite(n)) { | |
| setBad(true); | |
| return; | |
| } | |
| onCommit(n); | |
| return; | |
| } | |
| onCommit(text); | |
| }; | |
| if (kind === "boolean") | |
| return ( | |
| <select | |
| className="cg-json-leafedit" | |
| autoFocus | |
| value={String(value)} | |
| aria-label="Value" | |
| onChange={(e) => onCommit(e.target.value === "true")} | |
| onBlur={onCancel} | |
| > | |
| <option value="true">true</option> | |
| <option value="false">false</option> | |
| </select> | |
| ); | |
| return ( | |
| <input | |
| className={"cg-json-leafedit" + (bad ? " is-bad" : "")} | |
| autoFocus | |
| value={text} | |
| spellCheck={false} | |
| aria-label="Value" | |
| aria-invalid={bad || undefined} | |
| title={bad ? "That is not a number — the value was not changed." : undefined} | |
| onChange={(e) => { | |
| setText(e.target.value); | |
| if (bad) setBad(false); | |
| }} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); | |
| commit(); | |
| } else if (e.key === "Escape") { | |
| e.preventDefault(); | |
| onCancel(); | |
| } | |
| }} | |
| // Blur commits rather than discards: a person who clicks away from an edit box in a | |
| // document editor means "keep it" far more often than "throw it away", and Escape is the | |
| // discard that is actually reachable. | |
| onBlur={commit} | |
| /> | |
| ); | |
| } | |
| function Node({ | |
| name, | |
| value, | |
| depth, | |
| last, | |
| path, | |
| onValue, | |
| editing, | |
| onEditing, | |
| }: { | |
| name: string | null; | |
| value: unknown; | |
| depth: number; | |
| last: boolean; | |
| /** ⭐ R13 — where this node lives in the document, for `setAtPath`. */ | |
| path: (string | number)[]; | |
| /** ⭐ R13 — absent = the tree is READ-ONLY (the same condition `onSave` states one level up: | |
| * a machine-owned, Odoo-sourced or unpermitted field). Present = leaves are editable. */ | |
| onValue?: (path: (string | number)[], next: unknown) => void; | |
| /** The ONE leaf currently open for editing, as a joined path. Hoisted out of the node so | |
| * opening a second editor closes the first — per-node state would leave a trail of open | |
| * inputs down a document. */ | |
| editing?: string; | |
| onEditing?: (key: string | undefined) => void; | |
| }) { | |
| const [open, setOpen] = useState(depth < OPEN_TO_DEPTH); | |
| const pathKey = path.join(" |