본문 바로가기
Clone Coding

N8N & Zapier - Node selector

by zzuny-code 2026. 1. 22.
반응형

https://www.youtube.com/watch?v=ED2H_y6dmC8

09:50:42

 

Add "manual trigger" node → "수동 트리거" 노드 추가
Add "http request" node → "HTTP 요청" 노드 추가
Create node selector component → 노드 선택기 컴포넌트 생성

 

 

 

1. 데이터베이스 스키마 확장 - 새로운 노드 타입 추가

 

prisma/schema.prisma

  • Prisma 스키마에 새로운 노드 타입을 추가
enum NodeType {
  INITIAL
  MANUAL_TRIGGER  // 수동 트리거 노드
  HTTP_REQUEST    // HTTP 요청 노드
}
  • 변경사항을 데이터베이스에 반영하기 위해 마이그레이션을 실행
npx prisma migrate dev
// Enter a name for the new migration: › new-nodes

 

 

2. 노드 컴포넌트 설정 파일 준비

src/config/node-components.ts

  • 노드 타입과 컴포넌트를 매핑
  • 이 단계에서는 먼저 구조만 잡아둠
import { InitialNode } from "@/components/initial-node";
import { NodeType } from '@/generated/prisma/enums';
import type { NodeTypes } from "@xyflow/react"

export const nodeComponents = {
    [NodeType.INITIAL]: InitialNode,
    // 나중에 추가할 노드들
    // [NodeType.HTTP_REQUEST]: HttpRequestNode,
    // [NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
} as const satisfies NodeTypes;

export type RegisteredNodeType = keyof typeof nodeComponents;

 

 

3. 고유 ID 생성 라이브러리 설치

  • 노드마다 고유한 ID가 필요하므로 CUID2 라이브러리를 설치
npm install @paralleldrive/cuid2

 

 

4. 노드 선택기 컴포넌트 생성

src/components/node-selector.tsx

  • 사용자가 추가할 노드를 선택할 수 있는 UI
"use client"

...

// 노드 옵션 타입 정의
export type NodeTypeOption = {
    type: NodeType,
    label : string,
    description: string,
    icon: React.ComponentType<{ className?: string}>
}

// 트리거 노드와 실행 노드 정의
const triggerNodes: NodeTypeOption[]=[
    {
        type: NodeType.MANUAL_TRIGGER,
        label: "Trigger manually",
        description: "Runs the flow on clicking a button. Good for getting started quickly",
        icon: MousePointerIcon,
    }
]

const executionNodes: NodeTypeOption[] =[
    {
        type: NodeType.HTTP_REQUEST,
        label: "HTTP Request",
        description: "Makes an HTTP request",
        icon: GlobeIcon
    }
]

interface NodeSelectorProps{
    open: boolean;
    onOpenChage: (open:boolean) => void;
    children: React.ReactNode;
}


export function NodeSelector({
   open, 
   onOpenChage,
   children
}: NodeSelectorProps) {
    return(
        <Sheet open={open} onOpenChange={onOpenChage}>
            <SheetTrigger asChild>{children}</SheetTrigger>
            <SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
                <SheetHeader>
                    <SheetTitle>What triggers this workflow?</SheetTitle>
                    <SheetDescription>
                        A trigger is a step that starts your workflow.
                    </SheetDescription>
                </SheetHeader>
                {/* 트리거 노드 목록 */}
                <div>
                    {triggerNodes.map((nodeType) => (
                        // 노드 옵션 렌더링
                    ))}
                </div>
                <Separator/>
                {/* 실행 노드 목록 */}
                <div>
                    {executionNodes.map((nodeType) => (
                        // 노드 옵션 렌더링
                    ))}
                </div>
            </SheetContent>
        </Sheet>
    )
}

 

 

5. AddNodeButton에 노드 선택기 연결

src/features/editor/components/add-node-button.tsx

  • 버튼 클릭 시 노드 선택기가 열리도록 구현
"use client"

import { memo } from "react"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
import { NodeSelector } from "@/components/node-selector"
import { useState } from "react"

export const AddNodeButton = memo(()=>{

    const [selectorOpen, setSelectorOpen] = useState(false)

    return(
        <NodeSelector open={selectorOpen} onOpenChage={setSelectorOpen}>
            <Button
                onClick={()=>{}}
                size="icon"
                variant="outline"
                className="bg-background"
            >
                <PlusIcon/>
            </Button>
        </NodeSelector>
    )
})

AddNodeButton.displayName = "AddNodeButton";

 

버튼을 클릭하면 오른쪽에서 창이 뜸.

 

 

6. InitialNode에도 노드 선택기 추가

src/components/initial-node.tsx

  • 초기 노드를 클릭했을 때도 노드 선택기가 열리도록 수정
"use client"

import { memo } from "react"
import { NodeProps } from "@xyflow/react"
import { PlaceholderNode } from "./react-flow/placeholder-node"
import { PlusIcon } from "lucide-react"
import { WorkflowNode } from "./workflow-node"
import { NodeSelector } from "./node-selector"
import { useState } from "react"

export const InitialNode = memo((props:NodeProps) => {

    const [selectorOpen, setSelectorOpen] = useState(false)

    return(
        <NodeSelector open={selectorOpen} onOpenChage={setSelectorOpen}>
            <WorkflowNode showToolbar={false}>
                <PlaceholderNode
                    {...props}
                    onClick={()=>setSelectorOpen(true)}
                >
                    <div className="cursor-pointer flex items-center justify-center">
                        <PlusIcon className="size-4"/>
                    </div>
                </PlaceholderNode>
            </WorkflowNode>
        </NodeSelector>
    )
})

InitialNode.displayName = "InitialNode"

 

 

7. 노드 선택 로직 구현

src/components/node-selector.tsx 

  • 실제 노드 추가 로직을 구현
export function NodeSelector({
   open, 
   onOpenChage,
   children
}: NodeSelectorProps) {
    const {setNodes, getNodes, screenToFlowPosition} = useReactFlow();

    const handleNodeSelect = useCallback((selection: NodeTypeOption) => {
        // 수동 트리거는 하나만 허용
        if(selection.type === NodeType.MANUAL_TRIGGER) {
            const nodes = getNodes();
            const hasManualTrigger = nodes.some(
                (node) => node.type === NodeType.MANUAL_TRIGGER
            )

            if(hasManualTrigger) {
                toast.error("Only one manual trigger is allowed per workflow")
                return;
            }
        }

        setNodes((nodes) => {
            const hasInitialTrigger = nodes.some(
                (node) => node.type === NodeType.INITIAL
            );

            // 화면 중앙에서 랜덤한 위치 계산
            const centerX = window.innerWidth / 2
            const centerY = window.innerHeight / 2

            const flowPosition = screenToFlowPosition({
                x: centerX + (Math.random() - 0.5) * 200,
                y: centerY + (Math.random() - 0.5) * 200,
            });

            const newNode = {
                id: createId(),
                data: {},
                position: flowPosition,
                type: selection.type
            }

            // Initial 노드가 있으면 교체, 없으면 추가
            if(hasInitialTrigger) {
                return [newNode]
            }

            return [...nodes, newNode];
        });

        onOpenChage(false)
    }, [setNodes, getNodes, screenToFlowPosition, onOpenChage])

    // onClick 핸들러에 handleNodeSelect 연결
    // ...
}
  • 이제 노드 선택기에서 노드를 클릭하면 캔버스에 실제로 추가됨

 

 

작업전 참고

https://reactflow.dev/ui/components/base-handle

 

Base Handle - React Flow

A handle component with basic styling

reactflow.dev

 

8. React Flow Handle 컴포넌트 설치 및 재구성

  • 노드 간 연결을 위한 Handle 컴포넌트를 설치
npx shadcn@3.3.1 add https://ui.reactflow.dev/base-handle

 

  • 기존 src/components/base-handle.tsx 를 src/components/react-flow/base-handle.tsx 로 이동해서 구조를 정리함.

 

9. BaseExecutionNode 컴포넌트 구현

src/features/executions/components/base-execution-node.tsx

  • 실행 노드의 기본 구조를 정의하는 컴포넌트를 만든다.
"use client"

import { type NodeProps, Position } from "@xyflow/react"
import type { LucideIcon } from "lucide-react"
import Image from "next/image"
import { memo, type ReactNode} from 'react'
import { BaseNode, BaseNodeContent } from '@/components/react-flow/base-node' 
import { BaseHandle } from "./react-flow/base-handle"
import { WorkflowNode } from "./workflow-node"

interface BaseExecutionNodeProps extends NodeProps{
    icon: LucideIcon | string;
    name: string;
    description?:string;
    children?: ReactNode;
    // status?: NodeStatus;
    onSettings?: () => void;
    onDoubleClick?: () => void

}

export const BaseExecutionNode = memo(
    ({
        id,
        icon: Icon,
        name,
        description,
        children,
        onSettings,
        onDoubleClick 
}:BaseExecutionNodeProps) => {

    // TODO : add delete methid
    const handleDelete = () => {};

    return(
       
        <WorkflowNode
            name={name}
            description={description}
            onDelete={handleDelete}
            onSettings={onSettings}
        >
             {/* TODO : Wrap within NodeStatusIndicator*/}
            <BaseNode onDoubleClick={onDoubleClick}>
                <BaseNodeContent>
                    {typeof Icon === "string" ? (
                        <Image src={Icon} alt={name} width={16} height={16}/>
                    ) : (
                        <Icon className="size-4 text-muted-foreground"/>
                    )}
                    {children}
                    <BaseHandle
                        id="target-1"
                        type="target"
                        position={Position.Left}
                    />
                    <BaseHandle
                        id="source-1"
                        type="source"
                        position={Position.Right}
                    />
                </BaseNodeContent>
            </BaseNode>
        </WorkflowNode>
    )

})

BaseExecutionNode.displayName = "BaseExecutionNode"

 

 

10. HTTP Request 노드 구현

src/features/executions/components/http-request/node.tsx

type HttpRequestNodeData = {
    endpoint?: string;
    method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
    body?: string;
    [key: string]: unknown
};

export const HttpRequestNode = memo((props: NodeProps<HttpRequestNodeType>) => {
    const nodeData = props.data as HttpRequestNodeData;
    const description = nodeData?.endpoint
        ? `${nodeData.method || "GET"} : ${nodeData.endpoint}`
        : "Not configured";

    return(
        <BaseExecutionNode
            {...props}
            id={props.id}
            icon={GlobeIcon}
            name="HTTP Request"
            description={description}
            onSettings={() => {}}
            onDoubleClick={() => {}}
        />
    )
})

HttpRequestNode.displayName = "HttpRequestNode"

 

 

11. 노드 컴포넌트 등록 (HTTP Request)

src/config/node-components.ts

  • HTTP Request 노드를 등록
export const nodeComponents = {
    [NodeType.INITIAL]: InitialNode,
    [NodeType.HTTP_REQUEST]: HttpRequestNode,
    // [NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
} as const satisfies NodeTypes;

 

 

 

 

12. BaseTriggerNode 컴포넌트 구현

src/features/triggers/components/base-trigger-node.tsx

  • 트리거 노드의 기본 구조를 정의한다. 실행 노드와 다르게 source 핸들만 있다.
export const BaseTriggerNode = memo(({
    id,
    icon: Icon,
    name,
    description,
    children,
    onSettings,
    onDoubleClick 
}: BaseTriggerNodeProps) => {
    const handleDelete = () => {};

    return(
        <WorkflowNode
            name={name}
            description={description}
            onDelete={handleDelete}
            onSettings={onSettings}
        >
            <BaseNode onDoubleClick={onDoubleClick} className="rounded-l-2x1 relative group">
                <BaseNodeContent>
                    {typeof Icon === "string" ? (
                        <Image src={Icon} alt={name} width={16} height={16}/>
                    ) : (
                        <Icon className="size-4 text-muted-foreground"/>
                    )}
                    {children}
                    {/* 트리거는 출력만 있음 */}
                    <BaseHandle
                        id="source-1"
                        type="source"
                        position={Position.Right}
                    />
                </BaseNodeContent>
            </BaseNode>
        </WorkflowNode>
    )
})

BaseTriggerNode.displayName = "BaseTriggerNode"

 

 

13. Manual Trigger 노드 구현

src/features/triggers/components/manual-tigger/node.tsx

export const ManualTriggerNode = memo((props: NodeProps) => {
    return(
        <BaseTriggerNode
            {...props}
            icon={MousePointerIcon}
            name="When clicking 'Execute workflow'"
        />
    )
})

 

 

14. 노드 컴포넌트 최종 등록

src/config/node-components.ts

  • 모든 노드 등록
import { InitialNode } from "@/components/initial-node";
import { HttpRequestNode } from "@/features/executions/components/http-request/node";
import { ManualTriggerNode } from "@/features/triggers/components/manual-tigger/node";
import { NodeType } from '@/generated/prisma/enums';
import type { NodeTypes } from "@xyflow/react"


export const nodeComponents = {
    [NodeType.INITIAL] : InitialNode,
    [NodeType.HTTP_REQUEST]: HttpRequestNode,
    [NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
    
} as const satisfies NodeTypes;

export type RegisteredNodeType = keyof typeof nodeComponents;

 

 

 

 

  • 노드 타입 확장: Prisma Enum을 활용한 타입 안전성 확보
  • 컴포넌트 재사용: Base 컴포넌트 패턴으로 중복 코드 최소화
  • 상태 관리: React Flow의 useReactFlow 훅을 활용한 노드 관리
  • UX 고려: 수동 트리거 중복 방지, 랜덤 위치 배치 등 사용자 경험 개선
  • 구조화: features 폴더로 기능별 코드 분리

 

반응형