Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions __tests__/composite/tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import {
findNode,
insertNode,
removeNode,
moveNode,
updateNodeProps,
depthOf,
MAX_NEST_DEPTH,
} from "@/lib/composite/tree";
import type { PrimitiveNode } from "@/lib/composite/types";

const node = (id: string, type: PrimitiveNode["type"] = "heading"): PrimitiveNode => ({
id,
type,
props: { text: id },
...(type === "container" ? { children: [] } : {}),
});

/** a, then box[b, inner[c]] */
function tree(): PrimitiveNode[] {
return [
node("a"),
{
...node("box", "container"),
children: [node("b"), { ...node("inner", "container"), children: [node("c")] }],
},
];
}

describe("findNode", () => {
it("finds a top-level node", () => {
expect(findNode(tree(), "a")?.id).toBe("a");
});

it("finds a deeply nested node", () => {
expect(findNode(tree(), "c")?.id).toBe("c");
});

it("returns null when the id is absent", () => {
expect(findNode(tree(), "nope")).toBeNull();
});
});

describe("insertNode", () => {
it("appends to the top level when no parent is given", () => {
const next = insertNode(tree(), null, node("new"));

expect(next.map((n) => n.id)).toEqual(["a", "box", "new"]);
});

it("appends into the named container", () => {
const next = insertNode(tree(), "box", node("new"));

expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["b", "inner", "new"]);
});

it("appends into a nested container", () => {
const next = insertNode(tree(), "inner", node("new"));

expect(findNode(next, "inner")?.children?.map((n) => n.id)).toEqual(["c", "new"]);
});

it("does not mutate the input", () => {
const original = tree();
insertNode(original, "box", node("new"));

expect(findNode(original, "box")?.children).toHaveLength(2);
});

it("refuses to nest into a non-container", () => {
const next = insertNode(tree(), "a", node("new"));

expect(findNode(next, "new")).toBeNull();
});
});

describe("removeNode", () => {
it("removes a top-level node", () => {
expect(removeNode(tree(), "a").map((n) => n.id)).toEqual(["box"]);
});

it("removes a nested node", () => {
const next = removeNode(tree(), "b");

expect(findNode(next, "b")).toBeNull();
expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["inner"]);
});

it("removes a container along with its children", () => {
const next = removeNode(tree(), "box");

expect(findNode(next, "c")).toBeNull();
expect(next.map((n) => n.id)).toEqual(["a"]);
});

it("does not mutate the input", () => {
const original = tree();
removeNode(original, "b");

expect(findNode(original, "b")).not.toBeNull();
});
});

describe("moveNode", () => {
it("reorders within the top level", () => {
expect(moveNode(tree(), "box", "up").map((n) => n.id)).toEqual(["box", "a"]);
});

it("reorders within a container, not across parents", () => {
const next = moveNode(tree(), "inner", "up");

expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["inner", "b"]);
expect(next.map((n) => n.id)).toEqual(["a", "box"]);
});

it("is a no-op at the start of its sibling list", () => {
expect(moveNode(tree(), "a", "up").map((n) => n.id)).toEqual(["a", "box"]);
});

it("is a no-op at the end of its sibling list", () => {
const next = moveNode(tree(), "inner", "down");

expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["b", "inner"]);
});
});

describe("updateNodeProps", () => {
it("updates a nested node's props", () => {
const next = updateNodeProps(tree(), "c", { text: "changed" });

expect(findNode(next, "c")?.props.text).toBe("changed");
});

it("leaves siblings alone and does not mutate the input", () => {
const original = tree();
const next = updateNodeProps(original, "b", { text: "changed" });

expect(findNode(next, "c")?.props.text).toBe("c");
expect(findNode(original, "b")?.props.text).toBe("b");
});
});

describe("depthOf", () => {
it("reports 0 for a top-level node", () => {
expect(depthOf(tree(), "box")).toBe(0);
});

it("reports nesting depth", () => {
expect(depthOf(tree(), "b")).toBe(1);
expect(depthOf(tree(), "c")).toBe(2);
});

it("returns -1 for an unknown id", () => {
expect(depthOf(tree(), "nope")).toBe(-1);
});

it("agrees with the renderer's ceiling", () => {
// The renderer drops anything deeper than MAX_DEPTH, so the builder must not let
// the user create nodes it would refuse to draw.
expect(MAX_NEST_DEPTH).toBe(2);
});
});
85 changes: 41 additions & 44 deletions components/sandbox/builder-node-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ function NodeRow({
? String(node.props.text ?? node.props.label ?? "")
: "";
const isSelected = selectedNodeId === node.id;
const isNested = depth > 0;

return (
<>
Expand All @@ -96,50 +95,48 @@ function NodeRow({
{preview.length > 20 ? preview.slice(0, 20) + "..." : preview}
</span>
)}
{!isNested && (
<div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
onMove(node.id, "up");
}}
disabled={index === 0}
title="Move up"
>
<ChevronUp className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
onMove(node.id, "down");
}}
disabled={index === total - 1}
title="Move down"
>
<ChevronDown className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-400 hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
title="Delete"
>
<Trash2 className="size-3" />
</Button>
</div>
)}
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
onMove(node.id, "up");
}}
disabled={index === 0}
title="Move up"
>
<ChevronUp className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
onMove(node.id, "down");
}}
disabled={index === total - 1}
title="Move down"
>
<ChevronDown className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-400 hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
title="Delete"
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
{/* Render children for containers (display only — nested operations not supported) */}
{/* Children are fully operable: select, reorder within their parent, delete */}
{node.type === "container" &&
node.children?.map((child, ci) => (
<NodeRow
Expand Down
12 changes: 10 additions & 2 deletions components/sandbox/builder-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ const primitives: { type: PrimitiveType; label: string; icon: React.ReactNode }[

type BuilderPaletteProps = {
onAdd: (type: PrimitiveType) => void;
/** Container id new primitives will go into, or null for the top level. */
insertingInto?: string | null;
};

export function BuilderPalette({ onAdd }: BuilderPaletteProps) {
export function BuilderPalette({ onAdd, insertingInto = null }: BuilderPaletteProps) {
return (
<div>
<div className="text-xs font-semibold text-muted-foreground mb-2">Primitives</div>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">Primitives</span>
{/* Where a click will put the node - otherwise nesting is invisible guesswork */}
<span className="text-[10px] text-muted-foreground/60">
{insertingInto ? "into selected container" : "at top level"}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{primitives.map(({ type, label, icon }) => (
<Button
Expand Down
58 changes: 34 additions & 24 deletions components/sandbox/component-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { getDefaultProps, primitiveSchemas } from "@/lib/composite/primitives";
import { applyTheme } from "@/lib/theme/css-vars";
import { newCompositeId } from "@/lib/composite/identity";
import { ModalOverlay } from "./modal-overlay";
import {
findNode,
insertNode,
removeNode,
moveNode,
updateNodeProps,
depthOf,
MAX_NEST_DEPTH,
} from "@/lib/composite/tree";
import {
Select,
SelectContent,
Expand Down Expand Up @@ -171,37 +180,38 @@ export function ComponentBuilder({
}, [themeTokens]);

const selectedNode = useMemo(
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
() => (selectedNodeId ? findNode(nodes, selectedNodeId) : null),
[nodes, selectedNodeId],
);

const handleAddPrimitive = useCallback((type: PrimitiveType) => {
const defaultProps = getDefaultProps(type);
const newNode: PrimitiveNode = {
id: nextNodeId(),
type,
props: defaultProps,
...(type === "container" ? { children: [] } : {}),
};
setNodes((prev) => [...prev, newNode]);
setSelectedNodeId(newNode.id);
}, []);
// New primitives land inside the selected container when there is room, otherwise at
// the top level. The palette says which, so it is never a guess.
const insertParent = useMemo(() => {
if (!selectedNode || selectedNode.type !== "container") return null;
return depthOf(nodes, selectedNode.id) < MAX_NEST_DEPTH ? selectedNode : null;
}, [nodes, selectedNode]);

const handleAddPrimitive = useCallback(
(type: PrimitiveType) => {
const newNode: PrimitiveNode = {
id: nextNodeId(),
type,
props: getDefaultProps(type),
...(type === "container" ? { children: [] } : {}),
};
setNodes((prev) => insertNode(prev, insertParent?.id ?? null, newNode));
setSelectedNodeId(newNode.id);
},
[insertParent],
);

const handleMoveNode = useCallback((id: string, direction: "up" | "down") => {
setNodes((prev) => {
const idx = prev.findIndex((n) => n.id === id);
if (idx < 0) return prev;
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= prev.length) return prev;
const next = [...prev];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
return next;
});
setNodes((prev) => moveNode(prev, id, direction));
}, []);

const handleDeleteNode = useCallback(
(id: string) => {
setNodes((prev) => prev.filter((n) => n.id !== id));
setNodes((prev) => removeNode(prev, id));
if (selectedNodeId === id) setSelectedNodeId(null);
},
[selectedNodeId],
Expand All @@ -210,7 +220,7 @@ export function ComponentBuilder({
const handleNodePropsChange = useCallback(
(props: Record<string, unknown>) => {
if (!selectedNodeId) return;
setNodes((prev) => prev.map((n) => (n.id === selectedNodeId ? { ...n, props } : n)));
setNodes((prev) => updateNodeProps(prev, selectedNodeId, props));
},
[selectedNodeId],
);
Expand Down Expand Up @@ -344,7 +354,7 @@ export function ComponentBuilder({
<div className="flex-1 grid grid-cols-[300px_1fr_280px] overflow-hidden">
{/* Left: Palette + Node Tree + Node Props */}
<div className="border-r border-border/50 overflow-y-auto p-3 space-y-4">
<BuilderPalette onAdd={handleAddPrimitive} />
<BuilderPalette onAdd={handleAddPrimitive} insertingInto={insertParent?.id ?? null} />
<BuilderNodeTree
nodes={nodes}
selectedNodeId={selectedNodeId}
Expand Down
Loading
Loading