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