feat(expr): JsonTree with click-to-emit-path leaves
This commit is contained in:
61
frontend/src/expr/JsonTree.test.tsx
Normal file
61
frontend/src/expr/JsonTree.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
151
frontend/src/expr/JsonTree.tsx
Normal file
151
frontend/src/expr/JsonTree.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user