Skip to content

Commit 5289398

Browse files
authored
Merge pull request #14 from WebCreatorX/feat/canvas-rendering
📝 PR Title: 재귀적 노드 트리 렌더링 엔진 구현 및 테스트
2 parents 3f8ef9f + 31b4cd4 commit 5289398

File tree

18 files changed

+2136
-38
lines changed

18 files changed

+2136
-38
lines changed

apps/editor/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,24 @@
2525
"@tailwindcss/postcss": "^4",
2626
"@tanstack/eslint-plugin-query": "^5.91.2",
2727
"@tanstack/react-query-devtools": "^5.90.2",
28+
"@testing-library/dom": "^10.4.1",
29+
"@testing-library/jest-dom": "^6.9.1",
30+
"@testing-library/react": "^16.3.1",
2831
"@types/node": "^20",
2932
"@types/react": "^19",
3033
"@types/react-dom": "^19",
3134
"@typescript-eslint/eslint-plugin": "^8.46.4",
3235
"@typescript-eslint/parser": "^8.46.4",
36+
"@vitejs/plugin-react": "^5.1.2",
3337
"eslint": "^9.39.1",
3438
"eslint-config-next": "15.5.6",
3539
"eslint-plugin-immutable": "^1.0.0",
40+
"happy-dom": "^20.1.0",
41+
"jsdom": "^27.4.0",
3642
"prettier": "^3.6.2",
3743
"prettier-plugin-tailwindcss": "^0.7.1",
3844
"tailwindcss": "^4",
39-
"typescript": "^5"
45+
"typescript": "^5",
46+
"vitest": "^4.0.16"
4047
}
4148
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
useCanvas,
3+
useCurNodes,
4+
useSelectedNodeId,
5+
useSelectNode,
6+
useUpdateNode,
7+
} from "@/stores/useEditorStore";
8+
import EditorNodeWrapper from "@repo/ui/core/EditorNodeWrapper.jsx";
9+
import NodeRenderer from "@repo/ui/core/NodeRenderer.jsx";
10+
import { WcxNode } from "@repo/ui/types/nodes.js";
11+
import React from "react";
12+
13+
export default function Canvas() {
14+
const nodes = useCurNodes();
15+
const selectedNodeId = useSelectedNodeId();
16+
const selectNode = useSelectNode();
17+
const updateNode = useUpdateNode();
18+
const canvasState = useCanvas();
19+
20+
//FIXME-각 노드들에 key속성 추가해주기. -> 리액트 경고 발생
21+
//FIXME-nodes가 비어있는 상황에서 에러발생. -> Base Condition에 Root가 들어간다.(Root는 단지 더미 노드일뿐 로직에 들어가면 안된다.)
22+
/**
23+
*
24+
* @param parentNode 부모 노드 객체
25+
* @returns 부모 노드 트리 구조
26+
*
27+
* parentNode만 주면 NodeTree함수가 알아서 ParentNode의 자식 노드 객체 배열(WcxNode[])을 찾아준다.
28+
*/
29+
function renderTree(parentNode: WcxNode | { id: null }) {
30+
//parentNode의 자식 찾기
31+
const childrenObjArr = nodes?.filter(
32+
({ parent_id }) => parent_id === parentNode.id,
33+
);
34+
35+
//BaseCondition
36+
//FIXME-솔직히 !childrenArr만 있어도 될듯? 길이가 0일 수가 없다.
37+
if (!childrenObjArr || childrenObjArr.length === 0) {
38+
if (parentNode.id === null) return;
39+
return (
40+
<EditorNodeWrapper
41+
node={parentNode}
42+
selectedId={selectedNodeId}
43+
updateNode={updateNode}
44+
selectNode={selectNode}
45+
canvas={canvasState}
46+
>
47+
<NodeRenderer node={parentNode} />
48+
</EditorNodeWrapper>
49+
);
50+
}
51+
52+
// 1. 자식들의 렌더링 결과물 (JSX 배열)
53+
//현재 parentNode에 대해 NodeRenderer를 사용하려면 children이 필요한데, 재귀로 구해준다.
54+
const children = childrenObjArr.map((node) => {
55+
return <React.Fragment key={node.id}>{renderTree(node)}</React.Fragment>;
56+
});
57+
58+
// 2. [예외 처리] Root 노드인 경우 -> 그냥 자식들만 반환 (Wrapper 없음)
59+
if (parentNode.id === null) {
60+
return <>{children}</>;
61+
}
62+
63+
// 3. 일반 노드인 경우 -> Wrapper + NodeRenderer + children
64+
return (
65+
<EditorNodeWrapper
66+
node={parentNode}
67+
selectedId={selectedNodeId}
68+
updateNode={updateNode}
69+
selectNode={selectNode}
70+
canvas={canvasState}
71+
>
72+
<NodeRenderer node={parentNode}>{children}</NodeRenderer>
73+
</EditorNodeWrapper>
74+
);
75+
}
76+
77+
return <div className="canvas-root relative">{renderTree({ id: null })}</div>;
78+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { render, screen } from "@testing-library/react";
2+
import Canvas from "../Canvas";
3+
import { describe, it, expect, vi, beforeEach } from "vitest";
4+
5+
// Mock zustand stores
6+
const mockUseCurNodes = vi.fn();
7+
const mockUseSelectedNodeId = vi.fn();
8+
const mockUseSelectNode = vi.fn();
9+
const mockUseUpdateNode = vi.fn();
10+
const mockUseCanvas = vi.fn();
11+
const mockUseSetCanvas = vi.fn();
12+
13+
//실제 파일을 가로채서 가짜 결과를 내뱉게 합니다.
14+
vi.mock("@/stores/useEditorStore", () => ({
15+
useCurNodes: () => mockUseCurNodes(), //지연 실행 -> 이렇게 되면 다른 테스트에서 목함수의 반환값을 바꿔도 언제나 새롭게 해당 함수가 호출 되므로 다른 테스트의 영향을 받지 않는다.
16+
useSelectedNodeId: () => mockUseSelectedNodeId(),
17+
useSelectNode: () => mockUseSelectNode(),
18+
useUpdateNode: () => mockUseUpdateNode(),
19+
useCanvas: () => mockUseCanvas(),
20+
useSetCanvas: () => mockUseSetCanvas(),
21+
}));
22+
23+
// Mock EditorNodeWrapper (Rnd 라이브러리는 jsdom환경에서 테스트 하기에 까다롭습니다.)
24+
vi.mock("@repo/ui/core/EditorNodeWrapper.jsx", () => ({
25+
default: ({
26+
children,
27+
node,
28+
}: {
29+
children: React.ReactNode;
30+
node: { id: string };
31+
}) => (
32+
<div data-testid={`wrapper-${node.id}`} className="mock-wrapper">
33+
{children}
34+
</div>
35+
),
36+
}));
37+
38+
// Mock NodeRenderer
39+
vi.mock("@repo/ui/core/NodeRenderer.jsx", () => ({
40+
default: ({
41+
children,
42+
node,
43+
}: {
44+
children: React.ReactNode;
45+
node: { id: string };
46+
}) => (
47+
<div data-testid={`renderer-${node.id}`} className="mock-renderer">
48+
{children}
49+
</div>
50+
),
51+
}));
52+
53+
describe("Canvas Component", () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks();
56+
mockUseSelectedNodeId.mockReturnValue(null);
57+
mockUseCanvas.mockReturnValue({ scale: 1 });
58+
});
59+
60+
it("Case 1: Should render empty when there are no nodes", () => {
61+
mockUseCurNodes.mockReturnValue([]);
62+
const { container } = render(<Canvas />);
63+
expect(container.querySelector(".canvas-root")).toBeInTheDocument();
64+
expect(container.querySelectorAll(".mock-wrapper")).toHaveLength(0);
65+
});
66+
67+
it("Case 2: Should render flat nodes (Siblings)", () => {
68+
const flatNodes = [
69+
{ id: "1", type: "Container", parent_id: null, layout: {} },
70+
{ id: "2", type: "Hero", parent_id: null, layout: {} },
71+
];
72+
mockUseCurNodes.mockReturnValue(flatNodes);
73+
74+
const { container } = render(<Canvas />);
75+
76+
expect(screen.getByTestId("wrapper-1")).toBeInTheDocument();
77+
expect(screen.getByTestId("wrapper-2")).toBeInTheDocument();
78+
expect(screen.getByTestId("renderer-1")).toBeInTheDocument();
79+
expect(container.querySelectorAll(".mock-wrapper")).toHaveLength(2);
80+
});
81+
82+
it("Case 3: Should render nested nodes (Parent-Child)", () => {
83+
const nestedNodes = [
84+
{ id: "parent", type: "Container", parent_id: null, layout: {} },
85+
{ id: "child1", type: "Button", parent_id: "parent", layout: {} },
86+
{ id: "child2", type: "Button", parent_id: "parent", layout: {} },
87+
];
88+
mockUseCurNodes.mockReturnValue(nestedNodes);
89+
90+
render(<Canvas />);
91+
92+
// 존재 확인
93+
const parentWrapper = screen.getByTestId("wrapper-parent");
94+
const child1Wrapper = screen.getByTestId("wrapper-child1");
95+
const child2Wrapper = screen.getByTestId("wrapper-child2");
96+
97+
expect(parentWrapper).toBeInTheDocument();
98+
expect(child1Wrapper).toBeInTheDocument();
99+
expect(child2Wrapper).toBeInTheDocument();
100+
101+
// 핵심 검증: 계층 구조 확인
102+
// 부모의 렌더러 안에 자식의 래퍼가 들어있는가?
103+
// Canvas가 재귀를 제대로 돌리지 않았으면, 자식이 부모 밖에 튀어 나와 있었을 것.
104+
const parentRenderer = screen.getByTestId("renderer-parent");
105+
expect(parentRenderer).toContainElement(child1Wrapper);
106+
expect(parentRenderer).toContainElement(child2Wrapper);
107+
});
108+
109+
it("Case 4: 부모-자식1-자식2-손자 관계의 트리도 렌더링이 되야 합니다.", () => {
110+
const nestedNodes = [
111+
{ id: "parent", type: "Container", parent_id: null, layout: {} },
112+
{ id: "child1", type: "Button", parent_id: "parent", layout: {} },
113+
{ id: "child2", type: "Button", parent_id: "parent", layout: {} },
114+
{ id: "grandson", type: "Button", parent_id: "child1", layout: {} },
115+
];
116+
117+
mockUseCurNodes.mockReturnValue(nestedNodes);
118+
119+
render(<Canvas />);
120+
const parentRenderer = screen.getByTestId("renderer-parent");
121+
const child1Wrapper = screen.getByTestId("wrapper-child1");
122+
const child1Renderer = screen.getByTestId("renderer-child1"); // 👈 child1의 "렌더러(속)"를 찾아야 합니다.
123+
const grandsonWrapper = screen.getByTestId("wrapper-grandson");
124+
125+
expect(parentRenderer).toContainElement(child1Wrapper);
126+
expect(child1Renderer).toContainElement(grandsonWrapper);
127+
128+
expect(child1Wrapper.parentElement).toBe(parentRenderer);
129+
expect(grandsonWrapper.parentElement).toBe(child1Renderer);
130+
131+
expect(grandsonWrapper.parentElement).not.toBe(parentRenderer);
132+
});
133+
134+
it("Case 5: Should NOT render wrapper for root (id: null)", () => {
135+
// This test specifically verifies that the root pseudo-node doesn't get a wrapper
136+
// If the bug exists, this might crash or render a weird wrapper
137+
mockUseCurNodes.mockReturnValue([
138+
{ id: "1", type: "Container", parent_id: null, layout: {} },
139+
]);
140+
141+
render(<Canvas />);
142+
143+
// We expect wrapper-1 to exist
144+
expect(screen.getByTestId("wrapper-1")).toBeInTheDocument();
145+
146+
// We expect NO "wrapper-null" or similar
147+
const wrappers = screen.getAllByTestId(/wrapper-/);
148+
expect(wrappers).toHaveLength(1);
149+
});
150+
});

apps/editor/src/setupTests.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "@testing-library/jest-dom";

apps/editor/src/stores/useEditorStore.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { WcxNode } from "@repo/ui/types/nodes.js";
2+
import { CanvasState, Layer } from "@repo/ui/types/rnd.js";
13
import { create } from "zustand";
24
import { combine, devtools } from "zustand/middleware";
35
import { immer } from "zustand/middleware/immer";
@@ -8,6 +10,8 @@ const useEditorStore = create(
810
combine(
911
{
1012
selectedNodeId: null as string | null,
13+
nodes: null as null | WcxNode[], //TODO- 추후에 현재 페이지에 해당하는 노드들을 받아오는 로직을 통해 해당 상태가 업데이트 되야 한다.
14+
canvas: { dx: 0, dy: 0, scale: 1 },
1115
},
1216
(set) => ({
1317
selectNode(id: string) {
@@ -28,6 +32,22 @@ const useEditorStore = create(
2832
"editorStore/clearNode",
2933
);
3034
},
35+
//TODO-updateNode액션 검토하기
36+
updateNode(targetNodeId: string, updates: Partial<Layer>) {
37+
set((state) => {
38+
const targetNode = state.nodes?.find(
39+
({ id }) => id === targetNodeId,
40+
);
41+
if (targetNode) {
42+
targetNode.layout = { ...targetNode.layout, ...updates };
43+
}
44+
});
45+
},
46+
setCanvas(updates: CanvasState) {
47+
set((state) => {
48+
state.canvas = { ...state.canvas, ...updates };
49+
});
50+
},
3151
}),
3252
),
3353
),
@@ -43,3 +63,11 @@ export const useSelectedNodeId = () =>
4363
export const useSelectNode = () => useEditorStore((store) => store.selectNode);
4464

4565
export const useClearNode = () => useEditorStore((store) => store.clearNode);
66+
67+
export const useCurNodes = () => useEditorStore((store) => store.nodes);
68+
69+
export const useUpdateNode = () => useEditorStore((store) => store.updateNode);
70+
71+
export const useCanvas = () => useEditorStore((store) => store.canvas);
72+
73+
export const useSetCanvas = () => useEditorStore((store) => store.setCanvas);

apps/editor/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@
2323
"@/*": ["src/*"],
2424
"@editor/*": ["src/packages/editor/*"],
2525
"@components/*": ["src/packages/editor/components/*"],
26-
"@renderer/*": ["src/packages/editor/nodeRenderer/*"]
26+
"@renderer/*": ["src/packages/editor/nodeRenderer/*"],
27+
"types": ["../../packages/ui/src/types"],
28+
"types/*": ["../../packages/ui/src/types/*"],
29+
"components/*": ["../../packages/ui/src/components/*"],
30+
"utils/*": ["../../packages/ui/src/utils/*"],
31+
"context/*": ["../../packages/ui/src/context/*"],
32+
"hooks/*": ["../../packages/ui/src/hooks/*"]
2733
}
2834
},
2935
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

apps/editor/vitest.config.mts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig } from "vitest/config";
2+
import react from "@vitejs/plugin-react";
3+
import path from "path";
4+
5+
export default defineConfig({
6+
plugins: [react()],
7+
test: {
8+
environment: "happy-dom",
9+
globals: true,
10+
setupFiles: "./src/setupTests.ts",
11+
alias: {
12+
"@": path.resolve(__dirname, "./src"),
13+
"@repo/ui": path.resolve(__dirname, "../../packages/ui/src"),
14+
"components": path.resolve(__dirname, "../../packages/ui/src/components"),
15+
"types": path.resolve(__dirname, "../../packages/ui/src/types"),
16+
"utils": path.resolve(__dirname, "../../packages/ui/src/utils"),
17+
"hooks": path.resolve(__dirname, "../../packages/ui/src/hooks"),
18+
"context": path.resolve(__dirname, "../../packages/ui/src/context"),
19+
},
20+
},
21+
});

packages/ui/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"name": "@repo/ui",
33
"version": "0.0.0",
44
"exports": {
5-
"./renderer": "./src/renderer/NodeRenderer.tsx",
65
"./*": "./src/*"
76
},
87
"scripts": {

packages/ui/src/components/Button.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { useActionHandler } from "hooks/useActionHandler";
33
import { ButtonNode, NodeComponentProps } from "types";
44
import processNodeStyles from "utils/processNodeStyles";
55

6-
export default function Button({
6+
export default function ButtonComponent({
77
node,
88
props,
99
style,
10+
children,
1011
}: NodeComponentProps<ButtonNode>) {
1112
const { mode } = useBuilderMode();
1213
const { text, action } = props;
@@ -38,6 +39,7 @@ export default function Button({
3839
onClick={clickHandler} //이벤트 연결
3940
>
4041
<span style={nodeStyleObj.text}>{text}</span>
42+
{children}
4143
</button>
4244
);
4345
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ContainerNode, NodeComponentProps } from "types";
2+
import processNodeStyles from "utils/processNodeStyles";
3+
4+
export default function Container({
5+
node,
6+
style,
7+
children,
8+
}: NodeComponentProps<ContainerNode>) {
9+
//스타일 변환
10+
const nodeStyleObj = processNodeStyles(style);
11+
12+
return (
13+
<div
14+
data-component-id={node.id}
15+
style={nodeStyleObj.root}
16+
className={style.root?.className}
17+
>
18+
{/* Container는 자식이 있을 경우 렌더링 */}
19+
{children}
20+
</div>
21+
);
22+
}

0 commit comments

Comments
 (0)