Skip to content

Commit 2987c39

Browse files
feat(details): lazy-loaded JSON viewer for large JSON-shaped string fields
ModelAdmins frequently surface structured payloads (parser outputs, audit blobs, webhook bodies, debug snapshots) as TextField / JSONField values whose string representation is large JSON. The detail page used to render those as raw monospace text — no highlighting, no collapse, no copy affordance. Operators select-all-copied with surrounding chrome and a 50 KB blob filled the entire viewport. Adds a small (3.6 KB) recursive JSON tree viewer in @dar/details, mounted via FieldValueView only when: 1. The field's value is a string. 2. The trimmed value's first character is { or [. 3. JSON.parse succeeds. 4. The string is at least 1024 chars (the issue-acceptance threshold). Behaviour: - Syntax-highlights keys, strings, numbers, booleans, null using the existing design-token palette (text-rose / text-green / text-blue / text-purple); dark mode covered with the matching dark: variants — no new theme rules introduced. - Collapsible objects + arrays via a chevron toggle; collapsed nodes show their item count ({…} N keys / [...] N items). Top-level + the next level start open; deeper levels start collapsed so a deeply nested blob does not fill the viewport on first paint. - Copy button anchored top-right writes the ORIGINAL string (not a re-formatted variant) to the clipboard; shows "Copied" check for 2s. - Renders inside the existing field-grid container so it inherits the card padding + border tokens — no novel chrome. - React.lazy + Suspense: the viewer ships as its own ~3.6 KB chunk (JsonViewer-*.js) that is loaded on demand. Detail pages without any JSON-shaped field never fetch the chunk. Verified via the vite build manifest — the main JS bundle gains ~1.8 KB for the lazy / Suspense plumbing, which is the only fixed cost; the rest of the feature is gated behind the lazy import. Fallback: a small JSON snippet (< 1024 chars), a string that fails JSON.parse, or any non-string value goes through renderValue unchanged — zero regression on the plain-text path. No new runtime dependency (the viewer is hand-rolled JSX; recursion is bounded by the JSON itself, which has no cycles after JSON.parse). No API contract change. Closes #576. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1c53053 commit 2987c39

3 files changed

Lines changed: 239 additions & 1 deletion

File tree

frontend/packages/details/src/FieldValueView.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// text (e.g. a `CharField` holding `<script>`) stays inert. The trust
99
// boundary is identical to Django's `mark_safe`. See SECURITY.md + #172.
1010

11+
import { Suspense, lazy } from 'react';
1112
import { Check, X } from 'lucide-react';
1213
import { Link } from 'react-router-dom';
1314

@@ -20,6 +21,37 @@ import {
2021
type FieldValue,
2122
} from '@dar/data';
2223

24+
// Lazy-loaded so detail pages whose models hold no large JSON-shaped
25+
// strings pay zero bundle weight for the viewer (#576). Vite splits the
26+
// import boundary into its own chunk; the fallback below is the same
27+
// monospace block we render below the threshold, so the transition is
28+
// imperceptible when the chunk arrives.
29+
const JsonViewer = lazy(() => import('./JsonViewer'));
30+
31+
// Threshold below which we don't bother with the viewer — a small JSON
32+
// blob renders fine as plain text and any chrome is overkill (#576).
33+
// 1 KB is the issue-acceptance threshold.
34+
const JSON_VIEWER_THRESHOLD = 1024;
35+
36+
// Cheap detection: a string field whose trimmed value starts with `{`
37+
// or `[` AND parses as JSON. We only call `JSON.parse` after the
38+
// length + bracket-shape gate to keep the read path cheap for every
39+
// detail-page render.
40+
function tryParseLargeJson(value: unknown): { raw: string; parsed: unknown } | null {
41+
if (typeof value !== 'string') return null;
42+
if (value.length < JSON_VIEWER_THRESHOLD) return null;
43+
const trimmed = value.trimStart();
44+
if (trimmed.length === 0) return null;
45+
const first = trimmed[0];
46+
if (first !== '{' && first !== '[') return null;
47+
try {
48+
const parsed = JSON.parse(value) as unknown;
49+
return { raw: value, parsed };
50+
} catch {
51+
return null;
52+
}
53+
}
54+
2355
interface FieldValueViewProps {
2456
value: FieldValue | undefined;
2557
/** The field's wire type — when `datetime`/`date`/`time`, the value is
@@ -65,6 +97,22 @@ export function FieldValueView({ value, type }: FieldValueViewProps) {
6597
}
6698
return <span className="font-medium text-gray-700">{value.label}</span>;
6799
}
100+
// JSON-shaped string fields (#576): a TextField / JSONField whose
101+
// string value parses as JSON and exceeds the threshold renders
102+
// through the syntax-highlighted, collapsible viewer with a
103+
// copy-the-original-string button. Below the threshold, or for
104+
// non-JSON strings, fall through to the plain `renderValue` path
105+
// — small snippets are fine as monospace text and any chrome would
106+
// be overkill (CLAUDE.md §7, no redundant chrome). The viewer is
107+
// lazy-loaded so detail pages without JSON fields pay nothing.
108+
const json = tryParseLargeJson(value);
109+
if (json) {
110+
return (
111+
<Suspense fallback={<pre className="whitespace-pre-wrap font-mono text-xs">{json.raw}</pre>}>
112+
<JsonViewer raw={json.raw} parsed={json.parsed} />
113+
</Suspense>
114+
);
115+
}
68116
// FileField / ImageField: a download link to the stored file (matching
69117
// Django admin's "Currently: <a>" affordance). No URL → plain name.
70118
if (isFileValue(value)) {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// JsonViewer (#576) — syntax-highlighted, collapsible JSON tree with a
2+
// copy-the-original-string button. Mounts inside the detail page's
3+
// existing field-value container, so it inherits the card padding +
4+
// dark-mode tokens (no novel chrome).
5+
//
6+
// Default export so the consumer can `React.lazy(() => import('./JsonViewer'))`
7+
// — anything that doesn't have a JSON field on screen does not pay the
8+
// bundle weight.
9+
//
10+
// No runtime dependency: this is ~2 KB of recursive JSX. The recursion
11+
// is bounded by the parsed structure itself, which the caller has
12+
// already `JSON.parse`d successfully — JSON has no cycles so the tree
13+
// is naturally finite.
14+
15+
import { useState, type ReactNode } from 'react';
16+
import { Check, ChevronRight, Copy } from 'lucide-react';
17+
18+
interface JsonViewerProps {
19+
/** The raw JSON string — copied verbatim by the copy button so the
20+
* operator never gets a re-formatted variant on the clipboard. */
21+
raw: string;
22+
/** The parsed value (call sites already `JSON.parse`d for detection;
23+
* pass it back so we don't re-parse). */
24+
parsed: unknown;
25+
}
26+
27+
export default function JsonViewer({ raw, parsed }: JsonViewerProps) {
28+
const [copied, setCopied] = useState(false);
29+
30+
async function copy(): Promise<void> {
31+
try {
32+
await navigator.clipboard.writeText(raw);
33+
setCopied(true);
34+
setTimeout(() => setCopied(false), 2000);
35+
} catch {
36+
// Clipboard write can fail on iframes / non-secure contexts —
37+
// ignore silently rather than surfacing a toast for a paste op.
38+
}
39+
}
40+
41+
return (
42+
<div className="relative w-full overflow-x-auto rounded border border-gray-200 bg-gray-50 p-3 font-mono text-xs leading-relaxed text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200">
43+
<button
44+
type="button"
45+
onClick={copy}
46+
aria-label="Copy JSON"
47+
title={copied ? 'Copied' : 'Copy'}
48+
className="absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded border border-gray-300 bg-white text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
49+
>
50+
{copied ? (
51+
<Check className="h-3.5 w-3.5 text-green-600" aria-hidden />
52+
) : (
53+
<Copy className="h-3.5 w-3.5" aria-hidden />
54+
)}
55+
</button>
56+
<Node value={parsed} depth={0} />
57+
</div>
58+
);
59+
}
60+
61+
// One JSON value — primitive, object, or array — rendered with the right
62+
// syntax-highlight class. Containers (object/array) carry a clickable
63+
// toggle that collapses to `{…} N keys` or `[…] N items`.
64+
function Node({ value, depth }: { value: unknown; depth: number }) {
65+
if (value === null) return <span className="text-purple-600 dark:text-purple-400">null</span>;
66+
if (typeof value === 'boolean')
67+
return (
68+
<span className="text-purple-600 dark:text-purple-400">{value ? 'true' : 'false'}</span>
69+
);
70+
if (typeof value === 'number')
71+
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span>;
72+
if (typeof value === 'string')
73+
return (
74+
<span className="text-green-700 dark:text-green-400">
75+
"{escapeForDisplay(value)}"
76+
</span>
77+
);
78+
if (Array.isArray(value)) return <ArrayNode value={value} depth={depth} />;
79+
if (typeof value === 'object') return <ObjectNode value={value as Record<string, unknown>} depth={depth} />;
80+
// Unknown type (shouldn't happen after JSON.parse — but render safely).
81+
return <span className="text-gray-500">{String(value)}</span>;
82+
}
83+
84+
function ObjectNode({ value, depth }: { value: Record<string, unknown>; depth: number }) {
85+
const keys = Object.keys(value);
86+
// Top-level + the next level open by default; deeper levels start
87+
// collapsed so a 50 KB blob doesn't fill the viewport on first paint.
88+
const [open, setOpen] = useState(depth < 2);
89+
if (keys.length === 0) return <span className="text-gray-500">{'{}'}</span>;
90+
return (
91+
<Block
92+
open={open}
93+
onToggle={() => setOpen((o) => !o)}
94+
collapsedLabel={`{…} ${keys.length} ${keys.length === 1 ? 'key' : 'keys'}`}
95+
openBracket="{"
96+
closeBracket="}"
97+
depth={depth}
98+
>
99+
{keys.map((k, i) => (
100+
<div key={k} className="pl-4">
101+
<span className="text-rose-700 dark:text-rose-400">"{escapeForDisplay(k)}"</span>
102+
<span className="text-gray-500">: </span>
103+
<Node value={value[k]} depth={depth + 1} />
104+
{i < keys.length - 1 ? <span className="text-gray-500">,</span> : null}
105+
</div>
106+
))}
107+
</Block>
108+
);
109+
}
110+
111+
function ArrayNode({ value, depth }: { value: unknown[]; depth: number }) {
112+
const [open, setOpen] = useState(depth < 2);
113+
if (value.length === 0) return <span className="text-gray-500">[]</span>;
114+
return (
115+
<Block
116+
open={open}
117+
onToggle={() => setOpen((o) => !o)}
118+
collapsedLabel={`[…] ${value.length} ${value.length === 1 ? 'item' : 'items'}`}
119+
openBracket="["
120+
closeBracket="]"
121+
depth={depth}
122+
>
123+
{value.map((v, i) => (
124+
<div key={i} className="pl-4">
125+
<Node value={v} depth={depth + 1} />
126+
{i < value.length - 1 ? <span className="text-gray-500">,</span> : null}
127+
</div>
128+
))}
129+
</Block>
130+
);
131+
}
132+
133+
// Shared open/close-with-toggle scaffolding for objects + arrays.
134+
function Block({
135+
open,
136+
onToggle,
137+
collapsedLabel,
138+
openBracket,
139+
closeBracket,
140+
depth,
141+
children,
142+
}: {
143+
open: boolean;
144+
onToggle: () => void;
145+
collapsedLabel: string;
146+
openBracket: string;
147+
closeBracket: string;
148+
depth: number;
149+
children: ReactNode;
150+
}) {
151+
return (
152+
<span>
153+
<button
154+
type="button"
155+
onClick={onToggle}
156+
aria-expanded={open}
157+
// Aligns the chevron baseline with the bracket character.
158+
className="inline-flex items-center align-baseline text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"
159+
>
160+
<ChevronRight
161+
className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
162+
aria-hidden
163+
/>
164+
</button>
165+
<span className="text-gray-500">{openBracket}</span>
166+
{open ? (
167+
<>
168+
{children}
169+
<div className={depth === 0 ? '' : 'pl-0'}>
170+
<span className="text-gray-500">{closeBracket}</span>
171+
</div>
172+
</>
173+
) : (
174+
<>
175+
<span className="px-1 text-gray-500">{collapsedLabel}</span>
176+
<span className="text-gray-500">{closeBracket}</span>
177+
</>
178+
)}
179+
</span>
180+
);
181+
}
182+
183+
// JSON's stringification rules are strict — render the content as text
184+
// (no inner markup) so a value like `<script>` stays inert.
185+
function escapeForDisplay(s: string): string {
186+
// Show the string contents as-is; React already escapes < / > / & in
187+
// text children, so this is a no-op transform that exists as a
188+
// semantic marker — DO NOT switch to dangerouslySetInnerHTML here.
189+
return s;
190+
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.0.3"
3+
version = "1.0.4"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

0 commit comments

Comments
 (0)