loopable / web /src /customer-grid /JsonViewer.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
30d8096 verified
Raw
History Blame Contribute Delete
44.5 kB
// ---------------------------------------------------------------------------
// 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">&quot;{v}&quot;</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 numberthe 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("");
if (!isBranch(value)) {
const canEdit = !!onValue && value !== null;
return (
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
{name !== null && <span className="cg-json-key">{name}</span>}
{name !== null && <span className="cg-json-punct">: </span>}
{canEdit && editing === pathKey ? (
<LeafEditor
value={value as string | number | boolean}
onCommit={(next) => {
onValue(path, next);
onEditing?.(undefined);
}}
onCancel={() => onEditing?.(undefined)}
/>
) : canEdit ? (
// A real button, so the keyboard reaches every leaf the mouse does. `null` is
// deliberately NOT editable: there is no type to preserve, so any edit would be a
// shape change, which belongs to the raw tab.
<button
type="button"
className="cg-json-leafbtn"
onClick={() => onEditing?.(pathKey)}
title="Edit this value"
>
<Leaf v={value} />
</button>
) : (
<Leaf v={value} />
)}
{!last && <span className="cg-json-punct">,</span>}
</div>
);
}
const array = Array.isArray(value);
const entries: [string, unknown][] = array
? (value as unknown[]).map((v, i) => [String(i), v])
: Object.entries(value as Record<string, unknown>);
const openMark = array ? "[" : "{";
const closeMark = array ? "]" : "}";
return (
<>
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
<button
type="button"
className={"cg-json-twist" + (open ? " is-open" : "")}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "the document"}`}
>
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden>
<path
d="M6 4l4 4-4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{name !== null && <span className="cg-json-key">{name}</span>}
{name !== null && <span className="cg-json-punct">: </span>}
<span className="cg-json-punct">{openMark}</span>
{/* Collapsed, the count IS the content — a bare `{…}` tells a reader
nothing about whether opening it is worth the click. */}
{!open && (
<span className="cg-json-count">
{entries.length} {array ? (entries.length === 1 ? "item" : "items")
: entries.length === 1 ? "key" : "keys"}
</span>
)}
{!open && <span className="cg-json-punct">{closeMark}{last ? "" : ","}</span>}
</div>
{open &&
entries.map(([k, v], i) => (
<Node
key={k}
name={array ? null : k}
value={v}
depth={depth + 1}
last={i === entries.length - 1}
path={[...path, array ? Number(k) : k]}
onValue={onValue}
editing={editing}
onEditing={onEditing}
/>
))}
{open && (
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
<span className="cg-json-punct">
{closeMark}
{last ? "" : ","}
</span>
</div>
)}
</>
);
}
// --- ⭐ wave-26 item 8 (contract C2): the POSTS reading ----------------------
//
// The owner's ask was that the `posts` cell "reads as POSTS, not raw JSON". The
// tree is a fine JSON reader and a poor post reader: `posts → 3 → caption` is
// four clicks to see one line somebody wrote. So a posts document opens on its
// OWN tab, and the tree and raw tabs stay exactly where they were — this adds a
// reading, it does not replace the document.
//
// ⛔ AN ABSENT METRIC RENDERS AS NOTHING, NEVER AS A ZERO. `postsWindowOf` keeps
// them `undefined` when unbought (C2: metrics are ABSENT KEYS when
// `postMetrics` was off), and the row below tests each one before painting it.
// A `0 views` on a post that was never measured is a fabricated measurement,
// and it is indistinguishable on screen from a real one.
/** One metric, or nothing at all. The guard IS the feature — see above. */
function Metric({ label, value }: { label: string; value: number | undefined }) {
if (value === undefined) return null;
return (
<span className="cg-posts-metric">
<b>{value.toLocaleString()}</b> {label}
</span>
);
}
function PostRow({ post }: { post: PostSummary }) {
const day = post.posted_at ? dayText(post.posted_at) : "";
/* ⚠ A ROW WITH NOTHING READABLE IS POSSIBLE BY CONSTRUCTION. `postsWindowOf` accepts any
object inside `posts` — deliberately, because refusing an unrecognised member would hide a
post the engine wrote in a shape this file has not learned yet. The engine's own writer
fills these, so in practice it takes a hand-edited document to get here; when it happens the
card must not paint as an empty rectangle, which reads as a rendering bug rather than as a
thin record. The shortcode is the post's identity and is the honest last resort. */
const bare = !day && !post.type && !post.caption && !post.url;
return (
<div className="cg-posts-row">
{bare && (
<span className="cg-posts-kind">{post.shortcode ?? "A post with no details"}</span>
)}
<div className="cg-posts-head">
{day && <span className="cg-posts-day">{day}</span>}
{/* The type is the post's own word (`reel`, `image`) — shown verbatim rather
than mapped, because a vocabulary this file invents would drift from the
vendor's the first time they add one. */}
{post.type && <span className="cg-posts-kind">{post.type}</span>}
{post.url && (
<a
className="cg-posts-link"
href={post.url}
target="_blank"
rel="noopener noreferrer"
>
Open
</a>
)}
</div>
{post.caption && <p className="cg-posts-caption">{post.caption}</p>}
{/* ⛔ NO `views` ROW HERE (2026-08-08 — instagram-capture.md §4e). The vendor's `views` is
delivered at ACCOUNT grain: one value repeated across every reel of a creator. Rendering
it beside this post's own likes and comments is exactly the reading that made it look
like a post metric in the first place, and this panel would have kept saying so after
the column was retired. `plays` is the honest per-reel field; it is omitted rather than
shown empty, because a blank metric chip claims we looked and got zero. */}
<div className="cg-posts-metrics">
<Metric label="likes" value={post.likes} />
<Metric label="comments" value={post.comments} />
</div>
</div>
);
}
function PostsPanel({ window: w }: { window: PostsWindow }) {
if (w.posts.length === 0)
return <p className="cg-json-empty">No posts were captured for this profile.</p>;
return (
<div className="cg-posts">
{/* ⛔ THE ONE SENTENCE THIS PANEL OWES A READER (DESIGN.md §4 — never over-explain).
It is a WINDOW: the append tables keep the whole series (R1/R3), so a reader must not
take these for every post the account has ever made. And when metrics were not bought,
saying so is the only way an empty metrics row reads as "not measured" rather than
"measured as nothing". */}
<p className="cg-posts-note">
The {w.posts.length} most recent post{w.posts.length === 1 ? "" : "s"}
{w.as_of ? `, as of ${dayText(w.as_of)}` : ""}
{w.metrics ? "." : " — engagement was not collected for this run."}
</p>
<ul className="cg-posts-list">
{w.posts.map((p, i) => (
<li key={p.shortcode ?? p.url ?? i} className="cg-posts-item">
<PostRow post={p} />
</li>
))}
</ul>
</div>
);
}
// --- the shared modal shell -------------------------------------------------
//
// ⭐ WAVE-27 item 13 (R13) — extracted from `JsonViewer`'s own chrome when the CODE editor
// arrived, because "a big value that opens in a modal with a title, a summary line, a copy
// button and a footer" is now two things rather than one. One shell, so the two cannot drift
// into looking like different products — the reason `assetUrl` is one resolver, applied to
// furniture instead of to URLs.
//
// It owns ONLY the chrome. Tabs, body and footer are children: what a document IS differs
// between a JSON payload and a snippet, and a shell that tried to own that would end up with a
// `mode` flag threaded through every branch.
function ViewerShell({
label,
summary,
kind,
panelRef,
titleId,
onClose,
onCopy,
copied,
children,
}: {
label: string;
summary: string;
/** `data-overlay-kind` — the overlay layer's own identifier, distinct per viewer. */
kind: string;
panelRef: React.RefObject<HTMLDivElement>;
titleId: string;
onClose: () => void;
onCopy: () => void;
copied: boolean;
children: ReactNode;
}) {
return (
<BodyPortal>
<div className="cg-record-backdrop">
<div
className="cg-json-modal"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-overlay-kind={kind}
tabIndex={-1}
>
<div className="cg-json-head">
<div>
<div className="cg-json-title" id={titleId}>{label}</div>
<div className="cg-json-sub">{summary}</div>
</div>
<div className="cg-json-headacts">
<button type="button" className="cg-json-btn" onClick={onCopy}>
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
className="cg-icon-btn"
onClick={onClose}
aria-label="Close viewer"
>
×
</button>
</div>
</div>
{children}
</div>
</div>
</BodyPortal>
);
}
// --- ⭐ WAVE-27 item 13 (R13): the CODE editor ------------------------------
//
// A snippet is not a document, and the difference decides everything below:
//
// · IT NEED NOT PARSE. Half-written SQL is the normal state of a snippet, so there is no
// validate-on-save and no refusal — the only limit is the byte cap the store enforces
// anyway. This is the one rule `json` has that `code` deliberately does not.
// · THERE IS NO TREE. A snippet has no structure to project, so there is one panel, not tabs.
// · THE HIGHLIGHTER IS A RENDERER, NEVER AN INTERPRETER (R13: "NO execution engine"). It
// colours strings, comments, numbers and a per-language keyword list. Nothing here can run,
// fetch or evaluate anything, and no `language` value opens a path to something that could.
//
// The editor is a transparent-text `<textarea>` over a `<pre>` painted with the same text: the
// standard underlay, chosen over a contentEditable because a textarea keeps native undo, native
// selection and IME composition, all of which a hand-rolled editor loses on day one. The two
// layers MUST share font, size, line-height, padding and wrapping or the caret drifts from the
// glyphs — that pairing is stated in the CSS beside both rules, and the scroll sync below is the
// third leg of it.
interface Tok { t: string; c: string }
const LINE_COMMENT: Record<string, string[]> = {
json: [], plain: [],
sql: ["--"], python: ["#"], shell: ["#"], yaml: ["#"],
javascript: ["//"], typescript: ["//"], css: [], html: [], xml: [], markdown: [],
};
const BLOCK_COMMENT: Record<string, [string, string] | undefined> = {
javascript: ["/*", "*/"], typescript: ["/*", "*/"], css: ["/*", "*/"],
sql: ["/*", "*/"], html: ["<!--", "-->"], xml: ["<!--", "-->"],
};
const KEYWORDS: Record<string, string[]> = {
sql: ["select", "from", "where", "join", "left", "right", "inner", "outer", "on", "group",
"order", "by", "having", "limit", "offset", "insert", "into", "values", "update", "set",
"delete", "create", "table", "view", "as", "and", "or", "not", "null", "is", "in", "like",
"between", "case", "when", "then", "else", "end", "distinct", "union", "all", "with"],
python: ["def", "class", "return", "if", "elif", "else", "for", "while", "in", "not", "and",
"or", "import", "from", "as", "try", "except", "finally", "raise", "with", "lambda", "None",
"True", "False", "yield", "pass", "break", "continue", "global", "assert", "async", "await"],
javascript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "of",
"in", "new", "class", "extends", "import", "export", "from", "default", "try", "catch",
"finally", "throw", "async", "await", "typeof", "instanceof", "null", "undefined", "true",
"false", "this", "switch", "case", "break", "continue", "delete", "void", "yield"],
typescript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "of",
"in", "new", "class", "extends", "implements", "interface", "type", "enum", "import",
"export", "from", "default", "try", "catch", "finally", "throw", "async", "await", "typeof",
"instanceof", "null", "undefined", "true", "false", "this", "readonly", "public", "private",
"protected", "as", "satisfies", "switch", "case", "break", "continue"],
shell: ["if", "then", "else", "fi", "for", "in", "do", "done", "while", "case", "esac",
"function", "return", "export", "local", "echo", "cd", "set", "source"],
yaml: ["true", "false", "null"],
json: ["true", "false", "null"],
html: [], xml: [], css: [], markdown: [], plain: [],
};
const IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y;
const NUMBER = /\d+(\.\d+)?/y;
/**
* R13 — the tokenizer. Single left-to-right pass, no backtracking, no regex over the whole
* document: a 32 KB snippet must colour without blocking a keystroke.
*
* ⚠ EVERY branch consumes at least one character. A tokenizer that can advance zero characters
* hangs the tab, and it is the one bug in this shape that cannot be seen by reading the output.
*/
export function tokenizeCode(text: string, lang: string): Tok[] {
const out: Tok[] = [];
const keywords = new Set(KEYWORDS[lang] ?? []);
const lineMarks = LINE_COMMENT[lang] ?? [];
const block = BLOCK_COMMENT[lang];
const push = (t: string, c: string) => {
if (!t) return;
const prev = out[out.length - 1];
if (prev && prev.c === c) prev.t += t;
else out.push({ t, c });
};
let i = 0;
while (i < text.length) {
const ch = text[i];
// Block comment
if (block && text.startsWith(block[0], i)) {
const end = text.indexOf(block[1], i + block[0].length);
const stop = end < 0 ? text.length : end + block[1].length;
push(text.slice(i, stop), "com");
i = stop;
continue;
}
// Line comment
const mark = lineMarks.find((m) => text.startsWith(m, i));
if (mark) {
const nl = text.indexOf("\n", i);
const stop = nl < 0 ? text.length : nl;
push(text.slice(i, stop), "com");
i = stop;
continue;
}
// String — quotes close at the line end as well as at their pair, so one unterminated quote
// cannot paint the rest of the file as a string (the failure every naive highlighter has).
if (ch === '"' || ch === "'" || ch === "`") {
let j = i + 1;
while (j < text.length && text[j] !== ch && text[j] !== "\n") {
if (text[j] === "\\") j += 1;
j += 1;
}
const stop = j < text.length && text[j] === ch ? j + 1 : j;
push(text.slice(i, stop), "str");
i = stop;
continue;
}
if (ch >= "0" && ch <= "9") {
NUMBER.lastIndex = i;
const m = NUMBER.exec(text);
const t = m ? m[0] : ch;
push(t, "num");
i += t.length;
continue;
}
if (/[A-Za-z_$]/.test(ch)) {
IDENT.lastIndex = i;
const m = IDENT.exec(text);
const word = m ? m[0] : ch;
push(word, keywords.has(word) || keywords.has(word.toLowerCase()) ? "kw" : "");
i += word.length;
continue;
}
push(ch, "");
i += 1;
}
return out;
}
export interface CodeViewerProps {
label: string;
value: string;
language: string;
/** Absent = read-only, the same condition `JsonViewer` states. */
onSave?: (next: string) => void;
onClose: () => void;
}
export function CodeViewer({ label, value, language, onSave, onClose }: CodeViewerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const titleId = useId();
// ⚠ `value`, not `shownValue` — a `code` cell is never thinned. Only `json` columns carry a
// vendor document large enough to keep out of the list, so this component has nothing to fetch.
const [draft, setDraft] = useState(value);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
// Re-seeds when the CELL changes underneath, and only then — the JsonViewer rule, same reason.
useEffect(() => {
setDraft(value);
setError("");
}, [value]);
useOverlayLayer({
panelRef,
onDismiss: onClose,
dismissOnOutside: true,
initialFocus: "[data-overlay-autofocus]",
trapFocus: true,
});
const toks = useMemo(() => tokenizeCode(draft, language), [draft, language]);
const dirty = draft !== value;
const bytes = jsonByteLength(draft);
const lines = draft === "" ? 0 : draft.split("\n").length;
const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`;
const summary = draft.trim() === ""
? "Empty"
: `${lines} line${lines === 1 ? "" : "s"} · ${size}`;
const save = () => {
// ⛔ NO parse check, and its absence is the design. A snippet that does not compile is the
// normal state of one; refusing to save it would make the column useless for the thing it
// exists for. The byte cap still applies, because the STORE enforces it either way and a
// refusal the user only meets on the server is the one they cannot act on.
if (bytes > MAX_JSON_BYTES) {
setError(`That snippet is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB — `
+ "nothing was saved.");
return;
}
setError("");
onSave?.(draft);
onClose();
};
const copy = () => {
void navigator.clipboard?.writeText(draft).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
},
() => setError("The browser refused clipboard access — select the text and copy it.")
);
};
const painted = (
<pre className="cg-code-paint" ref={preRef} aria-hidden>
{toks.map((t, i) => (
<span key={i} className={t.c ? `cg-code-${t.c}` : undefined}>{t.t}</span>
))}
{/* A trailing newline has no glyph, so without this the last line scrolls out of step
with the textarea's own extra row. */}
{"\n"}
</pre>
);
return (
<ViewerShell
label={label}
summary={summary}
kind="code-viewer"
panelRef={panelRef}
titleId={titleId}
onClose={onClose}
onCopy={copy}
copied={copied}
>
<div className="cg-json-body">
<div className="cg-code-wrap">
{painted}
{onSave ? (
<textarea
className="cg-code-input"
value={draft}
spellCheck={false}
data-overlay-autofocus
aria-label={`${label} source`}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError("");
}}
// The third leg of the underlay: scroll the paint with the text, or the colours
// slide off the characters the moment the snippet is taller than the box.
onScroll={(e) => {
const el = preRef.current;
if (!el) return;
el.scrollTop = e.currentTarget.scrollTop;
el.scrollLeft = e.currentTarget.scrollLeft;
}}
/>
) : null}
</div>
</div>
{(error || (onSave && dirty)) && (
<div className="cg-json-foot">
{error ? (
<span className="cg-json-err">{error}</span>
) : (
<span className="cg-json-hint">Unsaved changes</span>
)}
{onSave && (
<span className="cg-json-footacts">
<button
type="button"
className="cg-json-btn"
onClick={() => {
setDraft(value);
setError("");
}}
>
Revert
</button>
<button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>
Save
</button>
</span>
)}
</div>
)}
</ViewerShell>
);
}
// --- the modal --------------------------------------------------------------
export interface JsonViewerProps {
/** The column's own label — the modal's title. */
label: string;
/** The stored document, as a string. */
value: string;
/** Absent = read-only (the field is machine-owned, Odoo-sourced, or this
* viewer permits the reader nothing). Present = the raw tab is an editor. */
onSave?: (next: string) => void;
onClose: () => void;
}
export default function JsonViewer({ label, value, onSave, onClose }: JsonViewerProps) {
/**
* ⭐⭐ 2026-08-10 — THE DOCUMENT IS FETCHED HERE, not shipped with every row.
*
* MEASURED on nurilab: `source_payload` was **95.6%-98.5% of every IG grid's bytes** — 11.6 MB
* of a 12.2 MB response on the post-measurement table — while the grid renders those cells as
* a sixty-character preview. So the list now ships a stand-in and this modal, the one place a
* person actually reads the document, asks for the real one.
*
* ⚠ THE STAND-IN CARRIES ITS OWN `_url`, so this component needs no table key and no record id
* threaded down to it: the route that removed the value says where it went. One writer of that
* address rather than a server rule and a client rule that must agree forever.
* ⚠ A FETCH FAILURE LEAVES THE STAND-IN ON SCREEN rather than blanking the modal — it says the
* document exists and is `bytes` long, which is strictly more than an empty box tells you.
*/
const [fetched, setFetched] = useState<string | null>(null);
useEffect(() => {
setFetched(null);
let live = true;
let url = "";
try {
const doc = JSON.parse(value) as { _truncated?: boolean; _url?: string };
if (doc && doc._truncated === true && typeof doc._url === "string") url = doc._url;
} catch {
/* not a stand-in — an ordinary document renders as it always has */
}
if (!url) return;
void fetch(url, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (live && j && typeof j.value === "string") setFetched(j.value);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [value]);
const shownValue = fetched ?? value;
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const tabsId = useId();
/** `null` = nobody has chosen one, so the landing tab is derived (see `shown`). */
const [picked, setPicked] = useState<"posts" | "tree" | "raw" | null>(null);
const [draft, setDraft] = useState(shownValue);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
// ⚠ The draft re-seeds when the CELL changes underneath (a machine write
// landing while the viewer is open), and only then — keying on the raw prop
// alone would also stamp on every keystroke's re-render if a parent echoed it back.
//
// ⛔ `shownValue`, NOT `value`, AND THAT ONE WORD WAS THE WHOLE BUG. Everything this modal
// renders comes from `draft` (`parsed = jsonParse(draft)`), so seeding it once from the prop
// meant the fetched document arrived, sat in `fetched`, and was never shown: the viewer kept
// displaying the `{"_truncated": …}` stand-in while the network tab showed a 200 with the real
// payload. MEASURED live — the request fired, succeeded, and changed nothing on screen, which
// is the most misleading shape a fix can have.
useEffect(() => {
setDraft(shownValue);
setError("");
}, [shownValue]);
useOverlayLayer({
panelRef,
onDismiss: onClose,
dismissOnOutside: true,
initialFocus: "[data-overlay-autofocus]",
trapFocus: true,
});
const parsed = useMemo(() => jsonParse(draft), [draft]);
const dirty = draft !== shownValue;
/** ⭐ R13 — which leaf is open for editing, as a joined path. ONE at a time (see `Node`). */
const [editingLeaf, setEditingLeaf] = useState<string | undefined>(undefined);
/**
* ⭐ R13 — a tree edit, applied to the RAW TEXT.
*
* This is what makes "two-way synced, raw wins" true without a reconciliation rule: the tree
* never holds a document of its own. It re-parses the current draft, writes one value, and
* serialises the whole thing back — so the raw tab and the tree are always literally the same
* string, and the next render's tree comes from that string rather than from what the tree
* thought it did.
*
* ⚠ Re-parsed HERE rather than reusing the `parsed` memo: between a click and a commit the raw
* tab may have changed the document, and `setAtPath` returning the root unchanged for a path
* that no longer resolves is the guard that keeps a stale edit from inventing keys.
*
* ⚠ `jsonPretty` reformats the WHOLE document, so a hand-indented payload is normalised by a
* tree edit. Stated rather than worked around: the alternative is a surgical text splice, which
* needs a position map from the parse — and a wrong splice writes a value into the wrong key.
*/
const editLeaf = (path: (string | number)[], next: unknown) => {
const now = jsonParse(draft);
if (!now.ok) return;
const updated = setAtPath(now.value, path, next);
if (updated === now.value) return; // stale path — nothing was written
setDraft(jsonPretty(JSON.stringify(updated)));
if (error) setError("");
};
/* ⭐ Item 8 — is this document a POSTS WINDOW? Read off the DRAFT, so editing the raw tab into
(or out of) the shape moves the tab with it rather than stranding a Posts tab over a
document that is no longer one. */
const posts = useMemo(
() => (parsed.ok ? postsWindowOf(parsed.value) : null),
[parsed]
);
/* The tab set is DERIVED from the document, and the shown tab is derived from the set — never
corrected by an effect. Two things fall out of that, both of which an effect gets wrong:
⚠ POSTS IS THE LANDING TAB when the document is one, which is the whole of the owner's ask
("reads as POSTS, not raw JSON"). A `useState` initialiser cannot do it: the cell value
arrives asynchronously and can change under an open viewer, so it would seed "tree" for
exactly the render that matters and the reader would meet the JSON first.
⚠ A tab the reader PICKED wins while it still exists — and stops winning the moment it does
not. Editing a posts document into something else on the raw tab would otherwise leave the
panel rendering a Posts tab that is no longer in the list above it. */
const tabs = posts ? (["posts", "tree", "raw"] as const) : (["tree", "raw"] as const);
const shown =
picked && (tabs as readonly string[]).includes(picked) ? picked : posts ? "posts" : "tree";
const save = () => {
const text = draft.trim();
// Blank is a legal edit: it clears the cell, which is how a document is
// removed. Everything else must parse before it is allowed to leave here.
if (text !== "") {
if (!jsonParse(text).ok) {
setError("That is not valid JSON — nothing was saved. Check for a trailing comma, a "
+ "single quote, or an unquoted key.");
return;
}
const bytes = jsonByteLength(text);
if (bytes > MAX_JSON_BYTES) {
setError(`That document is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB — `
+ "nothing was saved.");
return;
}
}
setError("");
onSave?.(text);
onClose();
};
const copy = () => {
void navigator.clipboard?.writeText(draft).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
},
() => setError("The browser refused clipboard access — select the raw text and copy it.")
);
};
return (
<ViewerShell
label={label}
/* The summary is the ONE explanatory line (DESIGN.md §4): what this document is, not
what a JSON field is for. */
summary={jsonSummary(draft)}
kind="json-viewer"
panelRef={panelRef}
titleId={titleId}
onClose={onClose}
onCopy={copy}
copied={copied}
>
<div className="cg-record-tabs" role="tablist" aria-label="Document view">
{tabs.map((k) => (
<button
key={k}
type="button"
role="tab"
id={`${tabsId}-${k}`}
aria-selected={shown === k}
aria-controls={`${tabsId}-panel`}
className={"cg-record-tab" + (shown === k ? " is-on" : "")}
onClick={() => setPicked(k)}
data-overlay-autofocus={k === tabs[0] ? true : undefined}
>
{k === "posts" ? "Posts" : k === "tree" ? "Tree" : "Raw"}
</button>
))}
</div>
<div
className="cg-json-body"
id={`${tabsId}-panel`}
role="tabpanel"
aria-labelledby={`${tabsId}-${shown}`}
>
{shown === "posts" && posts ? (
<PostsPanel window={posts} />
) : shown === "tree" ? (
parsed.ok ? (
<div className="cg-json-tree">
<Node
name={null}
value={parsed.value}
depth={0}
last
path={[]}
/* ⭐ R13the tree edits under exactly the condition the raw tab does.
`onSave` absent already means "this reader may not change the value", and
giving the tree a second, looser answer to that question is how one door
keeps taking writes after the other stops. */
onValue={onSave ? editLeaf : undefined}
editing={editingLeaf}
onEditing={setEditingLeaf}
/>
</div>
) : (
// ⛔ NOT an empty tree and NOT a repaired one. A document the app
// cannot open is a fact about the value, and the raw tab is where
// it gets read — so the empty state POINTS THERE rather than
// describing what JSON is.
<p className="cg-json-empty">
{draft.trim() === ""
? "Nothing here yet."
: "This value is not valid JSON — read it on the Raw tab."}
</p>
)
) : onSave ? (
<textarea
className="cg-json-raw"
value={draft}
spellCheck={false}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError("");
}}
aria-label={`${label} raw JSON`}
/>
) : (
<pre className="cg-json-raw cg-json-raw--ro">{jsonPretty(draft)}</pre>
)}
</div>
{(error || (onSave && dirty)) && (
<div className="cg-json-foot">
{error ? (
<span className="cg-json-err">{error}</span>
) : (
<span className="cg-json-hint">Unsaved changes</span>
)}
{onSave && (
<span className="cg-json-footacts">
<button
type="button"
className="cg-json-btn"
onClick={() => {
setDraft(value);
setError("");
}}
>
Revert
</button>
<button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>
Save
</button>
</span>
)}
</div>
)}
</ViewerShell>
);
}