본문 바로가기
Clone Coding

N8N & Zapier - Workflow Page

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

 

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

08:23:40 

 

전체 공부내용

Load workflow page by ID  ID로 워크플로우 페이지 불러오기

  • Prefetch  사전 가져오기
  • useSuspenseQuery  useSuspenseQuery 사용
  • Loading  로딩
  • Error  오류

 

Create "WorkflowHeader" component  →  "WorkflowHeader" 컴포넌트 생성

  • Update workflow name  →  워크플로우 이름 업데이트

 

Create "Editor" component    "Editor" 컴포넌트 생성

  • Add react-flow  →  react-flow 추가

 

 

 

 

Prefetch 

  • Next.js의 서버 컴포넌트에서 tRPC + React Query 데이터를 먼저 가져오는 단계.
    • [페이지 접속 → 클라이언트 JS 로드 → 데이터 요청 → 로딩 표시 → 완료] 과정에서
    • “깜빡이는 로딩 화면” 없이 렌더링
    • 클라이언트에서 다시 호출하지 않아도 됨 (워터폴 방지)

1. Prefetch 함수 정의

src/features/workflows/server/prefetch.ts

  • 서버에서 미리 데이터를 가져오는 명령을 정의
/* 
    prefetch a single workflow
*/
export const prefetchWorkflow = (id: string) => {
    return prefetch(trpc.workflows.getOne.queryOptions({ id }))
}

 

 

2. 클라이언트 Hook 정의

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

  • useSuspenseQuery는 데이터가 없으면 로딩 상태를 던지고, 있으면 바로 반환
export const useSuspenseWorkflow = (id:string) => {
    const trpc = useTRPC();
    return useSuspenseQuery(trpc.workflows.getOne.queryOptions({id}))
}

 

3. 페이지 컴포넌트에서 Prefetch 실행

src/app/(dashboard)/(editor)/workflows/[workflowId]/page.tsx

  • 서버 컴포넌트에서 페이지가 렌더링되기 전에 prefetchWorkflow를 실행해서 데이터를 미리 확보
  • 서버에서 데이터를 Prefetch 하더라도, 그 데이터를 클라이언트의 React Query가 전달받으려면 tRPC설정에 따라 HydrateClient로 컴포넌트를 감싸야 한다.
import { Editor, EditorError, EditorLoading } from "@/features/editor/components/editor";
import { prefetchWorkflow } from "@/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";

interface PageProps{
    params : Promise<{
        workflowId: string;
    }>
}
const Page = async({params}:PageProps) => {

    await requireAuth()
    const {workflowId} = await params;

	// 1. 데이터를 미리 확보
    prefetchWorkflow(workflowId)

    return (
        // <p>workflow id: {workflowId}</p>
        // 2. 서버에서 가져온 데이터를 클라이언트 QueryClient에 주입하는 래퍼가 필요함
        <HydrateClient>
        
        	// 3. 4번에서 에디터 컴포넌트 구현 예정
            <ErrorBoundary fallback={<EditorError/>}>
                <Suspense fallback={<EditorLoading/>}>
                	<Editor workflowId={workflowId}/>
                </Suspense>
            </ErrorBoundary>
        </HydrateClient>
    )
}

export default Page

 

 

4. 에디터 컴포넌트 구현

src/features/editor/components/editor.tsx

  • Prefetch된 데이터를 즉시 사용할 수 있다.
 "use client"

import { ErrorView, LoadingView } from "@/components/entity-components"
import { useSuspenseWorkflow } from "@/features/workflows/hooks/use-workflows"

export const EditorLoading = () => {
    return <LoadingView message="Loading editor..."/>
}

export const EditorError = () => {
    return <ErrorView message="Error loading editor..."/>
}

 export const Editor = ({workflowId}:{workflowId: string}) => {
    const {data: workflow} = useSuspenseWorkflow(workflowId)

    return(
        <p>
            {JSON.stringify(workflow, null, 2)}
        </p>
    )
 }

 

http://localhost:3000/workflows/cmkglyeuj0009orao3hegme15

 

 

 

워크플로우 이름 업데이트 기능

 

Mutation Hook 구현

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

export const useUpdateWorkflowName = () => {
    const queryClient = useQueryClient();
    const trpc = useTRPC();

    return useMutation(trpc.workflows.updateName.mutationOptions({
        onSuccess: (data) => {
            toast.success(`Workflow "${data.name}" updated`)
            // 캐시 무효화로 최신 데이터 반영
            queryClient.invalidateQueries(
                trpc.workflows.getMany.queryOptions({})
            );
            queryClient.invalidateQueries(
                trpc.workflows.getOne.queryOptions({id: data.id}),
            );
        },
        onError: (error) => {
            toast.error(`Failed to update workflow: ${error.message}`)
        }
    }))
}

 

 

에디터 헤더 구현

src/features/editor/components/editor-header.tsx

  • 워크플로우 이름을 클릭하면 편집 모드로 전환되는 인터랙티브한 UI를 구현
    • Enter 키로 저장, Escape 키로 취소
    • 포커스 아웃 시 자동 저장
    • 편집 모드 진입 시 텍스트 전체 선택
    • 변경사항이 없으면 저장 생략
"use client"

import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useSuspenseWorkflow, useUpdateWorkflowName } from "@/features/workflows/hooks/use-workflows"
import { SaveIcon } from "lucide-react"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"

export const EditorSaveButton =({workflowId} : {workflowId:string}) => {
    return(
        <div className="ml-auto">
            <Button size="sm" onClick={()=>{}} disabled={false}>
                <SaveIcon className="size-4"/>
                Save
            </Button>
        </div>
    )
}

export const EditorNameInput = ({workflowId} : {workflowId: string}) => {
    const {data: workflow} = useSuspenseWorkflow(workflowId)
    const updateWorkflow = useUpdateWorkflowName()

    const [isEditing, setIsEditing] = useState(false)
    const [name, setName] = useState(workflow.name)

    const inputRef = useRef<HTMLInputElement>(null)

    useEffect(()=>{
        if(workflow.name){
            setName(workflow.name);
        }
    },[workflow.name])

    useEffect(()=>{
        if(isEditing && inputRef.current){
            inputRef.current.focus();
            inputRef.current.select();
        }
    },[isEditing])

    const handleSave = async() => {
        if(name === workflow.name){
            setIsEditing(false);
            return;
        }

        try{
            await updateWorkflow.mutateAsync({
                id: workflowId,
                name,
            })
        }catch{
            setName(workflow.name)
        }finally{
            setIsEditing(false)
        }
    }

    const handleKeyDown = (e:React.KeyboardEvent) => {
        if(e.key === "Enter"){
            handleSave();
        }else if(e.key === "Escape"){
            setName(workflow.name);
            setIsEditing(false)
        }
    }

    if(isEditing){
        return(
            <Input
                disabled={updateWorkflow.isPending}
                ref={inputRef}
                value={name}
                onChange={(e) => setName(e.target.value)}
                onBlur={handleSave}
                onKeyDown={handleKeyDown}
                className="h-7 w-auto min-w-[100px] px-2"
            />
        )
    }



    return(
        <BreadcrumbItem
            onClick={()=> setIsEditing(true)}
            className="currsor-pointer hover:text-foreground transition-colors"
        >
            {workflow.name}
        </BreadcrumbItem>
    )
}

export const EditorBreadcrumbs =({workflowId} : {workflowId:string}) => {
    return(
        <Breadcrumb>
            <BreadcrumbList>
                <BreadcrumbItem>
                    <BreadcrumbLink asChild>
                        <Link prefetch href="/workflows">
                            Workflows
                        </Link>
                    </BreadcrumbLink>
                </BreadcrumbItem>
                <BreadcrumbSeparator/>
                <EditorNameInput workflowId={workflowId}/>
            </BreadcrumbList>
        </Breadcrumb>
    )
}

export const EditorHeader = ({workflowId} : {workflowId: string}) => {
    return(
        <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background">
            <SidebarTrigger/>
            <div className="flex flex-row items-center justify-between gap-x-4 w-full">
                <EditorBreadcrumbs workflowId={workflowId}/>
                <EditorSaveButton workflowId={workflowId}/>
            </div>
        </header>
    )
}

 

 

* 타입 안정성 개선

workflow.name의 타입이 string | null이어서 타입 에러가 발생

 

해결 방법 

  • findUnique 대신 findUniqueOrThrow를 사용:
    • 데이터가 없을 경우 에러를 발생시킴
    • 반환 타입이 null을 포함하지 않게 됨
    • 타입 안정성 확보

src/features/workflows/server/routers.ts

// getOne : protectedProcedure
//     .input(z.object({id: z.string()}))
//     .query(({ctx, input}) => {
//         return prisma.workflow.findUnique({
//             where: { id: input.id, userId: ctx.auth.user.id}
//         })
//     }),

getOne : protectedProcedure
    .input(z.object({id: z.string()}))
    .query(({ctx, input}) => {
        return prisma.workflow.findUniqueOrThrow({
            where: { id: input.id, userId: ctx.auth.user.id}
        })
    }),

 

 

src/app/(dashboard)/(editor)/workflows/[workflowId]/page.tsx

import { Editor, EditorError, EditorLoading } from "@/features/editor/components/editor";
import { prefetchWorkflow } from "@/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";

interface PageProps{
    params : Promise<{
        workflowId: string;
    }>
}
const Page = async({params}:PageProps) => {

    await requireAuth()
    const {workflowId} = await params;

    prefetchWorkflow(workflowId)
 
    return (
        // <p>workflow id: {workflowId}</p>
        <HydrateClient>
            <ErrorBoundary fallback={<EditorError/>}>
                <Suspense fallback={<EditorLoading/>}>
                <EditorHeader workflowId={workflowId}/>
                    <main className="flex-1">
                        <Editor workflowId={workflowId}/>
                    </main>
                </Suspense>
            </ErrorBoundary>
        </HydrateClient>
    )
}

export default Page

 

 

 

  • Prefetch 패턴: 서버 컴포넌트에서 데이터를 미리 가져와 UX 개선
  • HydrateClient: 서버 데이터를 클라이언트 React Query에 전달하는 핵심 래퍼
  • useSuspenseQuery: Suspense와 함께 사용하여 로딩 상태 자동 처리
  • Mutation + Cache Invalidation: 데이터 변경 후 캐시 무효화로 최신 상태 유지
  • 타입 안정성: findUniqueOrThrow로 null 타입 제거

 

반응형

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

N8N & Zapier - Node selector  (0) 2026.01.22
N8N & Zapier - Editor Setup  (1) 2026.01.20
N8N & Zapier - Workflows UI  (1) 2026.01.16
N8N & Zapier - Workflows Pagination  (0) 2026.01.15
N8N & Zapier - Workflows Crud / 2  (0) 2026.01.13