본문 바로가기
Clone Coding

N8N & Zapier - Workflows Crud / 2

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

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

6:08:36

 

 

10 - 재사용 가능한 Entity 컴포넌트

src/components/entity-components.tsx

 

  • EntityHeader - 유연한 버튼 처리
    • onNew prop: 함수 실행 (예: 모달 열기, API 호출)
    • newButtonHref prop: 페이지 이동 (Next.js Link)
    • 둘 다 없으면 버튼 미표시
    • TypeScript Discriminated Union으로 타입 안전성 보장
  • EntityContainer - 일관된 레이아웃
    • 헤더, 검색, 콘텐츠, 페이지네이션 슬롯 제공
    • 반응형 디자인 (모바일/데스크톱)
    • 최대 너비 제한으로 가독성 향상

 

import Link from "next/link";
import { Button } from "./ui/button";
import { PlusIcon } from "lucide-react";
import { ReactNode } from "react";

type EntityHeaderProps = {
    title: string;
    description?:string;
    newButtonLabel?:string;
    disabled?:boolean;
    isCreating?:boolean
} & (
    | {onNew: ()=> void; newButtonHref?:never}
    | {newButtonHref: string; onNew?:never}
    | {onNew?: never; newButtonHref?:never}
)

export const EntityHeader = ({
    title,
    description,
    onNew,
    newButtonHref,
    newButtonLabel,
    disabled,
    isCreating
}:EntityHeaderProps) => {
    return(
        <div className="flex flex-row items-center justify-between gap-x-4">
            <div className="flex flex-col">
                <h1 className="text-lg md:text-xl font-semibold">{title}</h1>
                {description &&(
                    <p className="text-xs md:text-sm text-muted-foreground">
                        {description}
                    </p>
                )}
            </div>
            {onNew && !newButtonHref && (
                <Button
                    disabled={isCreating || disabled} 
                    size="sm" 
                    onClick={onNew}
                >
                    <PlusIcon className="size-4"/>
                    {newButtonLabel}
                </Button>
            )}
            {newButtonHref && !onNew && (
                <Button
                    size="sm" 
                    asChild
                >
                    <Link href={newButtonHref} prefetch>
                        <PlusIcon className="size-4"/>
                        {newButtonLabel}
                    </Link>
                </Button>
            )}
        </div>
    )
}


type EntityContainerProps = {
    children: ReactNode;
    header?:ReactNode;
    search?:ReactNode;
    pagination?:ReactNode;
} 

export const EntityContainer = ({
    children,
    header,
    search,
    pagination,
}:EntityContainerProps) => {
    return(
        <div className="p-4 md:px-10 md:py-6 h-full">
            <div className="mx-auto max-w-screen-xl w-full flex flex-col gap-y-8 h-full">
                {header}
                <div className="flex flex-col gap-y-4 h-full">
                    {search}
                    {children}
                </div>
                {pagination}
            </div>
        </div>
    )
}

 

 

 

11 - Workflows UI 구현

 

11-1. Workflows 컴포넌트 완성

src/app/features/workflows/components/workflows.tsx

'use client'

import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useSuspenseWorkflows } from "../hooks/use-workflows"


export const WorkflowsList = () => {
    const workflows = useSuspenseWorkflows();

    return(
        <div className="flex-1 flex justify-center items-center">
            <p>
                {JSON.stringify(workflows.data, null, 2)}
            </p>
        </div>
    )
}

export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {
    return(
        <>
            <EntityHeader
                title="Workflows"
                description="Create and manage your workflows"
                onNew={()=>{}}
                newButtonLabel="New workflow"
                disabled={disabled}
                isCreating={false}
            /> 
        </>
    )
}

export const WorkflowsContainer = ({
    children
}:{
    children:React.ReactNode
})=>{
    return(
        <EntityContainer
            header={<WorkflowsHeader/>}
            search={<></>}
            pagination={<></>}
        >
            {children}
        </EntityContainer>
    )
}

 

 

11-2. 페이지 구조 업데이트

src/app/(dashboard)/(rest)/workflows/page.tsx

import { WorkflowsContainer, WorkflowsList } from "@/app/features/workflows/components/workflows";
import { prefetchWorkflows } from "@/app/features/workflows/server/prefetch";
import { requireAuth } from "@/lib/auth-utils";
import { HydrateClient } from "@/trpc/server";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

const Page = async() => {
    await requireAuth()

    prefetchWorkflows()
    return(
        <WorkflowsContainer>
            <HydrateClient>
                <ErrorBoundary fallback={<div>Error!</div>}>
                    <Suspense fallback={<p>Loading...</p>}>
                        <WorkflowsList/>
                    </Suspense>
                </ErrorBoundary>
            </HydrateClient>
        </WorkflowsContainer>
    )
}

export default Page;

 

 

 

 

12 - Workflow 생성 기능 구현

12-1. Create Workflow Hook 추가

 

src/app/features/workflows/hooks/use-workflows.ts

  • onSuccess: 생성 성공 시
    • 성공 토스트 메시지 표시
    • 생성된 워크플로우 상세 페이지로 이동
    • 워크플로우 목록 캐시 무효화 (새로고침)
  • onError: 실패 시 에러 토스트 표시
import { useTRPC } from "@/trpc/client"
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

export const useSuspenseWorkflows = () => {
    const trpc = useTRPC();

    return useSuspenseQuery(trpc.workflows.getMany.queryOptions());
}

export const useCreateWorkflow = () => {
    const router = useRouter()
    const queryClient = useQueryClient();
    const trpc = useTRPC();

    return useMutation(trpc.workflows.create.mutationOptions({
        onSuccess: (data) => {
            toast.success(`Workflow "${data.name}" created` )
            router.push(`/workflows/${data.id}`)
            queryClient.invalidateQueries(
                trpc.workflows.getMany.queryOptions()
            )
        },
        onError:(error) => {
            toast.error(`Failed to create workflow: ${error.message}`)
        }
    }))
}

 

 

12-2. Create 버튼 연결

src/app/features/workflows/components/workflows.tsx

  • "New workflow" 버튼 클릭
  • handleCreate 함수 실행
  • createWorkflow.mutate() 호출
  • 성공 시 → 생성된 워크플로우 페이지로 이동
  • 실패 시 → 에러 처리 (구독 필요 모달 표시 예정)
'use client'

import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useCreateWorkflow, useSuspenseWorkflows } from "../hooks/use-workflows"


export const WorkflowsList = () => {
    ...
}

export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {

    const createWorkflow = useCreateWorkflow()

    const handleCreate = () =>{
        createWorkflow.mutate(undefined, {
            onError:(error) => {
                // TODO: open upgrade modeal
                console.error(error)
            }
        })
    }
    return(
        <>
            <EntityHeader
                title="Workflows"
                description="Create and manage your workflows"
                onNew={handleCreate}
                newButtonLabel="New workflow"
                disabled={disabled}
                isCreating={createWorkflow.isPending}
            /> 
        </>
    )
}

export const WorkflowsContainer = ({
    children
}:{
    children:React.ReactNode
})=>{
    ...
}

 

버튼을 누르면 해당 아이디 페이지로 이동하고 

workflows 목록에는 생성된 정보들이 쌓인다.

 

 

 

13 - 구독 전용 기능 보호 

13-1. Upgrade Modal 컴포넌트

src/components/upgrade-modal.tsx

 

  • "Cancel" 버튼: 모달 닫기
  • "Upgrade Now" 버튼: Polar 결제 페이지로 이동

 

import { authClient } from "@/lib/auth-client";
import { AlertDialog, AlertDialogTitle, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogAction } from "./ui/alert-dialog";

interface UpgradeModalProps{
    open:boolean;
    onOpenChange: (open:boolean) => void
}

export const UpgradeModal = ({open, onOpenChange} : UpgradeModalProps) => {
    return(
        <AlertDialog open={open} onOpenChange={onOpenChange}>
            <AlertDialogContent>
                <AlertDialogHeader>
                    <AlertDialogTitle>Upgrade to Pro</AlertDialogTitle>
                    <AlertDialogDescription>
                        You need an active subscription to perform this action. upgrade to Pro to unlock all features.
                    </AlertDialogDescription>                    
                </AlertDialogHeader>
                <AlertDialogFooter>
                    <AlertDialogCancel>Cancel</AlertDialogCancel>
                    <AlertDialogAction onClick={()=>authClient.checkout({slug:"pro"})}>
                        Upgrade Now
                    </AlertDialogAction>
                </AlertDialogFooter>
            </AlertDialogContent>
        </AlertDialog>
    )
}

 

 

13-2. Upgrade Modal Hook

src/hooks/use-upgrade-modal.tsx

 

  • handleError: tRPC 에러 확인
    • FORBIDDEN 에러 → 모달 열기 (구독 필요)
    • 다른 에러 → false 반환
  • modal: 렌더링할 Modal 컴포넌트 제공

 

import { UpgradeModal } from "@/components/upgrade-modal";
import { TRPCClientError } from "@trpc/client";
import { useState } from "react"

export const useUpgradeModal = () => {
    const [open, setOpen] = useState(false);

    const handleError = (error:unknown) => {
        if(error instanceof TRPCClientError){
            if(error.data?.code === "FORBIDDEN"){
                setOpen(true);
                return true
            }
        }
        return false;
    };

    const modal = <UpgradeModal open={open} onOpenChange={setOpen}/>

    return { handleError, modal}
}

 

 

13-3. Workflows Header에 Modal 통합

src/app/features/workflows/components/workflows.tsx

'use client'

import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useCreateWorkflow, useSuspenseWorkflows } from "../hooks/use-workflows"
import { useUpgradeModal } from "@/hooks/use-upgrade-modal";


export const WorkflowsList = () => {
    const workflows = useSuspenseWorkflows();

    return(
        <div className="flex-1 flex justify-center items-center">
            <p>
                {JSON.stringify(workflows.data, null, 2)}
            </p>
        </div>
    )
}

export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {

    const createWorkflow = useCreateWorkflow()
    const {handleError, modal} = useUpgradeModal()

    const handleCreate = () =>{
        createWorkflow.mutate(undefined, {
            onSuccess: (data) => {
                router.push(`/workflows/${data.id}`)
            },
            onError:(error) => {
                handleError(error)
            }
        })
    }
    return(
        <>
            {modal}
            <EntityHeader
                title="Workflows"
                description="Create and manage your workflows"
                onNew={handleCreate}
                newButtonLabel="New workflow"
                disabled={disabled}
                isCreating={createWorkflow.isPending}
            /> 
        </>
    )
}

export const WorkflowsContainer = ({
    children
}:{
    children:React.ReactNode
})=>{
    return(
        <EntityContainer
            header={<WorkflowsHeader/>}
            search={<></>}
            pagination={<></>}
        >
            {children}
        </EntityContainer>
    )
}

 

 

13-4. Create API를 Premium Procedure로 변경

src/app/features/workflows/server/routers.ts

  • create만 premiumProcedure로 변경. 조회/수정/삭제는 무료 사용자도 가능.
import z from 'zod'
import {generateSlug} from "random-word-slugs"
import prisma from "@/lib/db";
import { createTRPCRouter, premiumProcedure, protectedProcedure } from "@/trpc/init";

export const workflowsRouter = createTRPCRouter({
    // create: protectedProcedure.mutation(({ctx}) => {
    create: premiumProcedure.mutation(({ctx}) => {
        return prisma.workflow.create({
            data : {
                // name: "TODO",
                name: generateSlug(3),
                userId: ctx.auth.user.id
            }
        })
    }),
    ...
})

 

 

13-5. Hook 리팩토링 (불필요한 라우팅 제거)

src/app/features/workflows/hooks/use-workflows.ts

 

  • 라우팅은 컴포넌트 레벨에서 처리
  • 더 유연한 에러 핸들링 가능

 

export const useCreateWorkflow = () => {
    // const router = useRouter()
    const queryClient = useQueryClient();
    const trpc = useTRPC();

    return useMutation(trpc.workflows.create.mutationOptions({
        onSuccess: (data) => {
            toast.success(`Workflow "${data.name}" created` )
            // router.push(`/workflows/${data.id}`)
            queryClient.invalidateQueries(
                trpc.workflows.getMany.queryOptions()
            )
        },
        onError:(error) => {
            toast.error(`Failed to create workflow: ${error.message}`)
        }
    }))
}

 

반응형

'Clone Coding' 카테고리의 다른 글

N8N & Zapier - Workflows UI  (1) 2026.01.16
N8N & Zapier - Workflows Pagination  (0) 2026.01.15
N8N & Zapier - Workflows Crud / 1  (0) 2026.01.12
N8N & Zapier - Payments Setup  (1) 2026.01.09
N8N & Zapier - Sidebar Layout  (0) 2026.01.07