feat(expr): JsonTree with click-to-emit-path leaves

This commit is contained in:
Claude
2026-08-30 11:08:52 +00:00
parent a131dbf198
commit 0afeed8ee7
2 changed files with 212 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { JsonTree } from "./JsonTree";
describe("JsonTree", () => {
it("renders top-level object keys collapsed by default", () => {
render(<JsonTree value={{ a: 1, b: { c: 2 } }} />);
expect(screen.getByText("a")).toBeInTheDocument();
expect(screen.getByText("b")).toBeInTheDocument();
expect(screen.queryByText("c")).not.toBeInTheDocument();
});
it("expands a nested object when the row is clicked", async () => {
render(<JsonTree value={{ a: { b: 2 } }} />);
await userEvent.click(screen.getByText("a"));
expect(screen.getByText("b")).toBeInTheDocument();
});
it("emits a dot-form path for a plain leaf on click", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ body: { email: "a@b" } }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("body"));
await userEvent.click(screen.getByText("email"));
expect(onPick).toHaveBeenCalledWith("body.email");
});
it("emits bracket-form for a key containing dots", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ "weird.key": "v" }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("weird.key"));
expect(onPick).toHaveBeenCalledWith(`["weird.key"]`);
});
it("emits bracket-form for a key containing dashes", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ "x-y": 1 }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("x-y"));
expect(onPick).toHaveBeenCalledWith(`["x-y"]`);
});
it("emits numeric index form for array elements", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ tags: ["a", "b", "c"] }} onPickPath={onPick} />);
await userEvent.click(screen.getByText("tags"));
await userEvent.click(screen.getByText("b"));
expect(onPick).toHaveBeenCalledWith("tags[1]");
});
it("does not fire onPickPath when pickable is false", async () => {
const onPick = vi.fn();
render(<JsonTree value={{ a: 1 }} onPickPath={onPick} pickable={false} />);
await userEvent.click(screen.getByText("a"));
expect(onPick).not.toHaveBeenCalled();
});
it("renders empty state for empty object", () => {
render(<JsonTree value={{}} />);
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,151 @@
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
interface Props {
value: unknown;
onPickPath?: (path: string) => void;
pickable?: boolean; // default true; false renders leaves non-clickable & dimmed
}
export function JsonTree({ value, onPickPath, pickable = true }: Props) {
if (isEmptyRoot(value)) {
return <div className="text-xs text-slate-500 italic">No data.</div>;
}
return <Node label="" value={value} path="" depth={0} pickable={pickable} onPick={onPickPath} />;
}
function isEmptyRoot(v: unknown): boolean {
if (v === null || v === undefined) return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v as object).length === 0;
return false;
}
interface NodeProps {
label: string;
value: unknown;
path: string;
depth: number;
pickable: boolean;
onPick?: (path: string) => void;
}
function Node({ label, value, path, depth, pickable, onPick }: NodeProps) {
// Root — render children as top-level rows without a wrapping expando.
if (depth === 0) {
return (
<ul className="text-xs font-mono">
{renderChildren(value, path, depth, pickable, onPick)}
</ul>
);
}
return (
<BranchOrLeaf
label={label}
value={value}
path={path}
depth={depth}
pickable={pickable}
onPick={onPick}
/>
);
}
function BranchOrLeaf({ label, value, path, depth, pickable, onPick }: NodeProps) {
const branch = isBranch(value);
const [open, setOpen] = useState(false);
const clickable = pickable;
const pickHandler = () => {
if (!pickable) return;
if (branch) setOpen((o) => !o);
else onPick?.(path);
};
return (
<li>
<button
type="button"
onClick={pickHandler}
className={
"flex items-center gap-1 w-full text-left py-0.5 px-1 rounded " +
(pickable ? "hover:bg-slate-800/80 " : "opacity-50 cursor-default ") +
(clickable ? "" : "cursor-default")
}
>
{branch ? (open ? <ChevronDown size={12} /> : <ChevronRight size={12} />) : <span className="w-3" />}
<span className="text-sky-300">{label}</span>
{!branch && (
<>
<span className="text-slate-500">:</span>
<span className="text-slate-300 truncate">{leafPreview(value)}</span>
</>
)}
{branch && (
<span className="text-slate-500 ml-1">{summarize(value)}</span>
)}
</button>
{branch && open && (
<ul className="ml-4 border-l border-slate-800 pl-2">
{renderChildren(value, path, depth, pickable, onPick)}
</ul>
)}
</li>
);
}
function renderChildren(
value: unknown, parentPath: string, depth: number,
pickable: boolean, onPick?: (path: string) => void,
) {
if (Array.isArray(value)) {
return value.map((v, i) => {
const childPath = `${parentPath}[${i}]`;
return (
<Node
key={i} label={String(i)} value={v} path={childPath}
depth={depth + 1} pickable={pickable} onPick={onPick}
/>
);
});
}
if (value !== null && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).map(([k, v]) => {
const seg = needsBracketForm(k) ? `["${k.replace(/"/g, `\\"`)}"]` : `.${k}`;
const childPath = parentPath === "" ? (needsBracketForm(k) ? seg : k) : `${parentPath}${seg}`;
return (
<Node
key={k} label={k} value={v} path={childPath}
depth={depth + 1} pickable={pickable} onPick={onPick}
/>
);
});
}
return null;
}
function isBranch(v: unknown): boolean {
return v !== null && typeof v === "object";
}
function leafPreview(v: unknown): string {
if (v === null) return "null";
if (typeof v === "string") return truncate(v, 40);
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n) + "…";
}
function summarize(v: unknown): string {
if (Array.isArray(v)) return `[${v.length}]`;
if (v !== null && typeof v === "object") {
const n = Object.keys(v as object).length;
return `{${n}}`;
}
return "";
}
function needsBracketForm(key: string): boolean {
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
}