Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
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
9 changes: 8 additions & 1 deletion apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,24 @@
"@tailwindcss/postcss": "^4",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.1",
"eslint-config-next": "15.5.6",
"eslint-plugin-immutable": "^1.0.0",
"happy-dom": "^20.1.0",
"jsdom": "^27.4.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.16"
}
}
78 changes: 78 additions & 0 deletions apps/editor/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
useCanvas,
useCurNodes,
useSelectedNodeId,
useSelectNode,
useUpdateNode,
} from "@/stores/useEditorStore";
import EditorNodeWrapper from "@repo/ui/core/EditorNodeWrapper.jsx";
import NodeRenderer from "@repo/ui/core/NodeRenderer.jsx";
import { WcxNode } from "@repo/ui/types/nodes.js";
import React from "react";

export default function Canvas() {
const nodes = useCurNodes();
const selectedNodeId = useSelectedNodeId();
const selectNode = useSelectNode();
const updateNode = useUpdateNode();
const canvasState = useCanvas();

//FIXME-각 λ…Έλ“œλ“€μ— key속성 μΆ”κ°€ν•΄μ£ΌκΈ°. -> λ¦¬μ•‘νŠΈ κ²½κ³  λ°œμƒ
//FIXME-nodesκ°€ λΉ„μ–΄μžˆλŠ” μƒν™©μ—μ„œ μ—λŸ¬λ°œμƒ. -> Base Condition에 Rootκ°€ λ“€μ–΄κ°„λ‹€.(RootλŠ” 단지 더미 λ…Έλ“œμΌλΏ λ‘œμ§μ— λ“€μ–΄κ°€λ©΄ μ•ˆλœλ‹€.)
/**
*
* @param parentNode λΆ€λͺ¨ λ…Έλ“œ 객체
* @returns λΆ€λͺ¨ λ…Έλ“œ 트리 ꡬ쑰
*
* parentNode만 μ£Όλ©΄ NodeTreeν•¨μˆ˜κ°€ μ•Œμ•„μ„œ ParentNode의 μžμ‹ λ…Έλ“œ 객체 λ°°μ—΄(WcxNode[])을 μ°Ύμ•„μ€€λ‹€.
*/
function renderTree(parentNode: WcxNode | { id: null }) {
//parentNode의 μžμ‹ μ°ΎκΈ°
const childrenObjArr = nodes?.filter(
({ parent_id }) => parent_id === parentNode.id,
);

//BaseCondition
//FIXME-μ†”μ§νžˆ !childrenArr만 μžˆμ–΄λ„ 될듯? 길이가 0일 μˆ˜κ°€ μ—†λ‹€.
if (!childrenObjArr || childrenObjArr.length === 0) {
if (parentNode.id === null) return;
return (
<EditorNodeWrapper
node={parentNode}
selectedId={selectedNodeId}
updateNode={updateNode}
selectNode={selectNode}
canvas={canvasState}
>
<NodeRenderer node={parentNode} />
</EditorNodeWrapper>
);
}

// 1. μžμ‹λ“€μ˜ λ Œλ”λ§ κ²°κ³Όλ¬Ό (JSX λ°°μ—΄)
//ν˜„μž¬ parentNode에 λŒ€ν•΄ NodeRendererλ₯Ό μ‚¬μš©ν•˜λ €λ©΄ children이 ν•„μš”ν•œλ°, μž¬κ·€λ‘œ ꡬ해쀀닀.
const children = childrenObjArr.map((node) => {
return <React.Fragment key={node.id}>{renderTree(node)}</React.Fragment>;
});

// 2. [μ˜ˆμ™Έ 처리] Root λ…Έλ“œμΈ 경우 -> κ·Έλƒ₯ μžμ‹λ“€λ§Œ λ°˜ν™˜ (Wrapper μ—†μŒ)
if (parentNode.id === null) {
return <>{children}</>;
}

// 3. 일반 λ…Έλ“œμΈ 경우 -> Wrapper + NodeRenderer + children
return (
<EditorNodeWrapper
node={parentNode}
selectedId={selectedNodeId}
updateNode={updateNode}
selectNode={selectNode}
canvas={canvasState}
>
<NodeRenderer node={parentNode}>{children}</NodeRenderer>
</EditorNodeWrapper>
);
}

return <div className="canvas-root relative">{renderTree({ id: null })}</div>;
}
150 changes: 150 additions & 0 deletions apps/editor/src/components/__tests__/Canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { render, screen } from "@testing-library/react";
import Canvas from "../Canvas";
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock zustand stores
const mockUseCurNodes = vi.fn();
const mockUseSelectedNodeId = vi.fn();
const mockUseSelectNode = vi.fn();
const mockUseUpdateNode = vi.fn();
const mockUseCanvas = vi.fn();
const mockUseSetCanvas = vi.fn();

//μ‹€μ œ νŒŒμΌμ„ κ°€λ‘œμ±„μ„œ κ°€μ§œ κ²°κ³Όλ₯Ό λ‚΄λ±‰κ²Œ ν•©λ‹ˆλ‹€.
vi.mock("@/stores/useEditorStore", () => ({
useCurNodes: () => mockUseCurNodes(), //μ§€μ—° μ‹€ν–‰ -> μ΄λ ‡κ²Œ 되면 λ‹€λ₯Έ ν…ŒμŠ€νŠΈμ—μ„œ λͺ©ν•¨μˆ˜μ˜ λ°˜ν™˜κ°’μ„ 바꿔도 μ–Έμ œλ‚˜ μƒˆλ‘­κ²Œ ν•΄λ‹Ή ν•¨μˆ˜κ°€ 호좜 λ˜λ―€λ‘œ λ‹€λ₯Έ ν…ŒμŠ€νŠΈμ˜ 영ν–₯을 λ°›μ§€ μ•ŠλŠ”λ‹€.
useSelectedNodeId: () => mockUseSelectedNodeId(),
useSelectNode: () => mockUseSelectNode(),
useUpdateNode: () => mockUseUpdateNode(),
useCanvas: () => mockUseCanvas(),
useSetCanvas: () => mockUseSetCanvas(),
}));

// Mock EditorNodeWrapper (Rnd λΌμ΄λΈŒλŸ¬λ¦¬λŠ” jsdomν™˜κ²½μ—μ„œ ν…ŒμŠ€νŠΈ ν•˜κΈ°μ— κΉŒλ‹€λ‘­μŠ΅λ‹ˆλ‹€.)
vi.mock("@repo/ui/core/EditorNodeWrapper.jsx", () => ({
default: ({
children,
node,
}: {
children: React.ReactNode;
node: { id: string };
}) => (
<div data-testid={`wrapper-${node.id}`} className="mock-wrapper">
{children}
</div>
),
}));

// Mock NodeRenderer
vi.mock("@repo/ui/core/NodeRenderer.jsx", () => ({
default: ({
children,
node,
}: {
children: React.ReactNode;
node: { id: string };
}) => (
<div data-testid={`renderer-${node.id}`} className="mock-renderer">
{children}
</div>
),
}));

describe("Canvas Component", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseSelectedNodeId.mockReturnValue(null);
mockUseCanvas.mockReturnValue({ scale: 1 });
});

it("Case 1: Should render empty when there are no nodes", () => {
mockUseCurNodes.mockReturnValue([]);
const { container } = render(<Canvas />);
expect(container.querySelector(".canvas-root")).toBeInTheDocument();
expect(container.querySelectorAll(".mock-wrapper")).toHaveLength(0);
});

it("Case 2: Should render flat nodes (Siblings)", () => {
const flatNodes = [
{ id: "1", type: "Container", parent_id: null, layout: {} },
{ id: "2", type: "Hero", parent_id: null, layout: {} },
];
mockUseCurNodes.mockReturnValue(flatNodes);

const { container } = render(<Canvas />);

expect(screen.getByTestId("wrapper-1")).toBeInTheDocument();
expect(screen.getByTestId("wrapper-2")).toBeInTheDocument();
expect(screen.getByTestId("renderer-1")).toBeInTheDocument();
expect(container.querySelectorAll(".mock-wrapper")).toHaveLength(2);
});

it("Case 3: Should render nested nodes (Parent-Child)", () => {
const nestedNodes = [
{ id: "parent", type: "Container", parent_id: null, layout: {} },
{ id: "child1", type: "Button", parent_id: "parent", layout: {} },
{ id: "child2", type: "Button", parent_id: "parent", layout: {} },
];
mockUseCurNodes.mockReturnValue(nestedNodes);

render(<Canvas />);

// 쑴재 확인
const parentWrapper = screen.getByTestId("wrapper-parent");
const child1Wrapper = screen.getByTestId("wrapper-child1");
const child2Wrapper = screen.getByTestId("wrapper-child2");

expect(parentWrapper).toBeInTheDocument();
expect(child1Wrapper).toBeInTheDocument();
expect(child2Wrapper).toBeInTheDocument();

// 핡심 검증: 계측 ꡬ쑰 확인
// λΆ€λͺ¨μ˜ λ Œλ”λŸ¬ μ•ˆμ— μžμ‹μ˜ λž˜νΌκ°€ λ“€μ–΄μžˆλŠ”κ°€?
// Canvasκ°€ μž¬κ·€λ₯Ό μ œλŒ€λ‘œ λŒλ¦¬μ§€ μ•Šμ•˜μœΌλ©΄, μžμ‹μ΄ λΆ€λͺ¨ 밖에 νŠ€μ–΄ λ‚˜μ™€ μžˆμ—ˆμ„ 것.
const parentRenderer = screen.getByTestId("renderer-parent");
expect(parentRenderer).toContainElement(child1Wrapper);
expect(parentRenderer).toContainElement(child2Wrapper);
});

it("Case 4: λΆ€λͺ¨-μžμ‹1-μžμ‹2-μ†μž κ΄€κ³„μ˜ νŠΈλ¦¬λ„ λ Œλ”λ§μ΄ λ˜μ•Ό ν•©λ‹ˆλ‹€.", () => {
const nestedNodes = [
{ id: "parent", type: "Container", parent_id: null, layout: {} },
{ id: "child1", type: "Button", parent_id: "parent", layout: {} },
{ id: "child2", type: "Button", parent_id: "parent", layout: {} },
{ id: "grandson", type: "Button", parent_id: "child1", layout: {} },
];

mockUseCurNodes.mockReturnValue(nestedNodes);

render(<Canvas />);
const parentRenderer = screen.getByTestId("renderer-parent");
const child1Wrapper = screen.getByTestId("wrapper-child1");
const child1Renderer = screen.getByTestId("renderer-child1"); // πŸ‘ˆ child1의 "λ Œλ”λŸ¬(속)"λ₯Ό μ°Ύμ•„μ•Ό ν•©λ‹ˆλ‹€.
const grandsonWrapper = screen.getByTestId("wrapper-grandson");

expect(parentRenderer).toContainElement(child1Wrapper);
expect(child1Renderer).toContainElement(grandsonWrapper);

expect(child1Wrapper.parentElement).toBe(parentRenderer);
expect(grandsonWrapper.parentElement).toBe(child1Renderer);

expect(grandsonWrapper.parentElement).not.toBe(parentRenderer);
});

it("Case 5: Should NOT render wrapper for root (id: null)", () => {
// This test specifically verifies that the root pseudo-node doesn't get a wrapper
// If the bug exists, this might crash or render a weird wrapper
mockUseCurNodes.mockReturnValue([
{ id: "1", type: "Container", parent_id: null, layout: {} },
]);

render(<Canvas />);

// We expect wrapper-1 to exist
expect(screen.getByTestId("wrapper-1")).toBeInTheDocument();

// We expect NO "wrapper-null" or similar
const wrappers = screen.getAllByTestId(/wrapper-/);
expect(wrappers).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions apps/editor/src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
28 changes: 28 additions & 0 deletions apps/editor/src/stores/useEditorStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { WcxNode } from "@repo/ui/types/nodes.js";
import { CanvasState, Layer } from "@repo/ui/types/rnd.js";
import { create } from "zustand";
import { combine, devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
Expand All @@ -8,6 +10,8 @@ const useEditorStore = create(
combine(
{
selectedNodeId: null as string | null,
nodes: null as null | WcxNode[], //TODO- 좔후에 ν˜„μž¬ νŽ˜μ΄μ§€μ— ν•΄λ‹Ήν•˜λŠ” λ…Έλ“œλ“€μ„ λ°›μ•„μ˜€λŠ” λ‘œμ§μ„ 톡해 ν•΄λ‹Ή μƒνƒœκ°€ μ—…λ°μ΄νŠΈ λ˜μ•Ό ν•œλ‹€.
canvas: { dx: 0, dy: 0, scale: 1 },
},
(set) => ({
selectNode(id: string) {
Expand All @@ -28,6 +32,22 @@ const useEditorStore = create(
"editorStore/clearNode",
);
},
//TODO-updateNodeμ•‘μ…˜ κ²€ν† ν•˜κΈ°
updateNode(targetNodeId: string, updates: Partial<Layer>) {
set((state) => {
const targetNode = state.nodes?.find(
({ id }) => id === targetNodeId,
);
if (targetNode) {
targetNode.layout = { ...targetNode.layout, ...updates };
}
});
},
setCanvas(updates: CanvasState) {
set((state) => {
state.canvas = { ...state.canvas, ...updates };
});
},
}),
),
),
Expand All @@ -43,3 +63,11 @@ export const useSelectedNodeId = () =>
export const useSelectNode = () => useEditorStore((store) => store.selectNode);

export const useClearNode = () => useEditorStore((store) => store.clearNode);

export const useCurNodes = () => useEditorStore((store) => store.nodes);

export const useUpdateNode = () => useEditorStore((store) => store.updateNode);

export const useCanvas = () => useEditorStore((store) => store.canvas);

export const useSetCanvas = () => useEditorStore((store) => store.setCanvas);
8 changes: 7 additions & 1 deletion apps/editor/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
"@/*": ["src/*"],
"@editor/*": ["src/packages/editor/*"],
"@components/*": ["src/packages/editor/components/*"],
"@renderer/*": ["src/packages/editor/nodeRenderer/*"]
"@renderer/*": ["src/packages/editor/nodeRenderer/*"],
"types": ["../../packages/ui/src/types"],
"types/*": ["../../packages/ui/src/types/*"],
"components/*": ["../../packages/ui/src/components/*"],
"utils/*": ["../../packages/ui/src/utils/*"],
"context/*": ["../../packages/ui/src/context/*"],
"hooks/*": ["../../packages/ui/src/hooks/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
Expand Down
21 changes: 21 additions & 0 deletions apps/editor/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
globals: true,
setupFiles: "./src/setupTests.ts",
alias: {
"@": path.resolve(__dirname, "./src"),
"@repo/ui": path.resolve(__dirname, "../../packages/ui/src"),
"components": path.resolve(__dirname, "../../packages/ui/src/components"),
"types": path.resolve(__dirname, "../../packages/ui/src/types"),
"utils": path.resolve(__dirname, "../../packages/ui/src/utils"),
"hooks": path.resolve(__dirname, "../../packages/ui/src/hooks"),
"context": path.resolve(__dirname, "../../packages/ui/src/context"),
},
},
});
1 change: 0 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "@repo/ui",
"version": "0.0.0",
"exports": {
"./renderer": "./src/renderer/NodeRenderer.tsx",
"./*": "./src/*"
},
"scripts": {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { useActionHandler } from "hooks/useActionHandler";
import { ButtonNode, NodeComponentProps } from "types";
import processNodeStyles from "utils/processNodeStyles";

export default function Button({
export default function ButtonComponent({
node,
props,
style,
children,
}: NodeComponentProps<ButtonNode>) {
const { mode } = useBuilderMode();
const { text, action } = props;
Expand Down Expand Up @@ -38,6 +39,7 @@ export default function Button({
onClick={clickHandler} //이벀트 μ—°κ²°
>
<span style={nodeStyleObj.text}>{text}</span>
{children}
</button>
);
}
22 changes: 22 additions & 0 deletions packages/ui/src/components/Container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ContainerNode, NodeComponentProps } from "types";
import processNodeStyles from "utils/processNodeStyles";

export default function Container({
node,
style,
children,
}: NodeComponentProps<ContainerNode>) {
//μŠ€νƒ€μΌ λ³€ν™˜
const nodeStyleObj = processNodeStyles(style);

return (
<div
data-component-id={node.id}
style={nodeStyleObj.root}
className={style.root?.className}
>
{/* ContainerλŠ” μžμ‹μ΄ μžˆμ„ 경우 λ Œλ”λ§ */}
{children}
</div>
);
}
Loading