본문 바로가기
Clone Coding

N8N & Zapier - Workflows UI

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

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

07:34:34

 

Create UI componentsUI 컴포넌트 생성

  • Loading로딩
  • Error에러(오류)
  • Empty비어 있음(데이터 없음)
  • List목록 컴포넌트
  • Item개별 항목 (날짜 표시 + 삭제 기능)

 

1. Loading 컴포넌트

1-1. LoadingView 생성

 

src/components/entity-components.tsx

 

  • Loader2Icon: 회전 애니메이션이 있는 로딩 스피너
  • animate-spin: Tailwind CSS 회전 애니메이션 클래스
  • !!message: 메시지가 있을 때만 표시 (옵셔널)

 

interface StateViewProps{
    message?:string;
}

export const LoadingView = ({
    message,
}:StateViewProps) => {
    return(
        <div className="flex justify-center items-center h-full flex-1 flex-col gap-y-4">
            <Loader2Icon className="size-6 animate-spin text-primary"/>
            {!!message && (
                <p className="text-sm text-muted-foreground">
                    {message}
                </p>
            )}
        </div>
    )
}

 

1-2. WorkflowsLoading 생성

 

src/features/workflows/components/workflows.tsx

import { LoadingView } from "@/components/entity-components";

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

 

1-3. 페이지에 적용

 

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

  • Suspense fallback: 데이터 로딩 중에 표시될 컴포넌트를 지정
import { WorkflowsContainer, WorkflowsList, WorkflowsLoading } from "@/features/workflows/components/workflows";
...

type Props = {
    searchParams: Promise<SearchParams>
}

const Page = async({searchParams}:Props) => {
    await requireAuth()

    const params = await workflowsParamsLoader(searchParams)

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

export default Page;

 


 

 

2. Error 컴포넌트

2-1. ErrorView 생성

 

src/components/entity-components.tsx

  • AlertTriangleIcon: 경고 아이콘 사용
export const ErrorView = ({
    message,
}:StateViewProps) => {
    return(
        <div className="flex justify-center items-center h-full flex-1 flex-col gap-y-4">
            <AlertTriangleIcon className="size-6 animate-spin text-primary"/>
            {!!message && (
                <p className="text-sm text-muted-foreground">
                    {message}
                </p>
            )}
        </div>
    )
}

 

2-2. WorkflowsError 생성

 

src/features/workflows/components/workflows.tsx

import { ErrorView } from "@/components/entity-components";

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

 

2-3. 페이지에 적용

 

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

import { WorkflowsError} from "@/features/workflows/components/workflows";

...

type Props = {
    searchParams: Promise<SearchParams>
}

const Page = async({searchParams}:Props) => {
    await requireAuth()

    const params = await workflowsParamsLoader(searchParams)

    prefetchWorkflows(params)
    return(
        <WorkflowsContainer>
            <HydrateClient>
                <ErrorBoundary fallback={<WorkflowsError/>}>
                    {/* <Suspense fallback={<p>Loading...</p>}> */}
                    <Suspense fallback={<WorkflowsLoading/>}>
                        <WorkflowsList/>
                    </Suspense>
                </ErrorBoundary>
            </HydrateClient>
        </WorkflowsContainer>
    )
}

export default Page;

 

2-4. 에러 테스트

  • 테스트 후 삭제: 에러가 제대로 표시되는지 확인한 후 throw new Error("test") 라인을 삭제

 

src/features/workflows/components/workflows.tsx

export const WorkflowsList = () => {
    throw new Error("test")
    
    const workflows = useSuspenseWorkflows();

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

 


 

3. Empty 컴포넌트

 

3-1. shadcn/ui Empty 컴포넌트 설치

 

공식 문서를 참고하여 Empty 컴포넌트를 설치

참고: shadcn/ui Empty Component

 

 

3-2. EmptyView 생성

 

  • Empty: shadcn/ui의 Empty 컨테이너
  • EmptyMedia: 아이콘 영역 (PackageOpenIcon)
  • EmptyTitle: 제목 ("No Items")
  • EmptyDescription: 설명 메시지 (옵셔널)
  • EmptyContent: 액션 버튼 영역 (onNew가 있을 때만 표시)

 

src/components/entity-components.tsx

interface EmptyViewProps extends StateViewProps{
    onNew?: ()=> void
}

export const EmptyView = ({
    message,
    onNew
}:EmptyViewProps) =>{
    return(
        <Empty className="border border-dashed bg-white">
            <EmptyHeader>
                <EmptyMedia variant="icon">
                    <PackageOpenIcon/>
                </EmptyMedia>
            </EmptyHeader>
            <EmptyTitle>
                No Items
            </EmptyTitle>
            {!!message && (
                <EmptyDescription>
                    {message}
                </EmptyDescription>
            )}
            {!!onNew && (
                <EmptyContent>
                    <Button onClick={onNew}>
                        Add item
                    </Button>
                </EmptyContent>
            )}
        </Empty>
    )
}

 

 

3-3. WorkflowsEmpty 생성

 

  • "Add item" 버튼 클릭
  • handleCreate 실행
  • 성공 시 → 생성된 워크플로우 페이지로 이동
  • 실패 시 (구독 필요) → Upgrade Modal 표시

 

src/features/workflows/components/workflows.tsx

export const WorkflowsEmpty = () => {

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

    const handleCreate = () => {
        createWorkflow.mutate(undefined, {
            onError: (error) => {
                handleError(error);
            },
            onSuccess: (data) => {
                router.push(`/workflows/${data.id}`)
            }
        })
    }

    return(
        <>
            {modal}
            <EmptyView
                onNew={()=>handleCreate}
                message="You haven't created any workflows yet. Get started by createing a workflow"
            />
        </>
    )
}

 

3-4. WorkflowsList에 Empty 상태 처리

 

src/features/workflows/components/workflows.tsx 파일 수정

export const WorkflowsList = () => {
    // throw new Error("test")

    const workflows = useSuspenseWorkflows();

    if(workflows.data.items.length === 0) {
        return(
            <WorkflowsEmpty/>
        )
    }

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

 


4. EntityList 컴포넌트 (제네릭 목록)

 

4-1. EntityList 생성

 

  • <T>: 어떤 타입의 데이터든 처리 가능 (재사용성 극대화)
  • renderItem: 각 아이템을 어떻게 렌더링할지 외부에서 결정
  • getKey: 고유 key 생성 방법 커스터마이징
  • emptyView: 빈 상태 UI 커스터마이징

 

src/components/entity-components.tsx

export function EntityList<T>({
    items,
    renderItem, 
    getKey, 
    emptyView, 
    className,
}:EntityListProps<T>){
    
    if(items.length === 0 && emptyView){
        return(
            <div className="flex-1 flex justify-center items-center">
                <div className="max-w-sm mx-auto">{emptyView}</div>
            </div>
        )
    }

    return(
        <div className={cn(
            "flex flex-col gap-y-4",
            className,
        )}>
            {items.map((item, index) => (
                <div key={getKey ? getKey(item, index) : index}>
                    {renderItem(item, index)}
                </div>
            ))}
        </div>
    )
}

 

4-2. WorkflowsList에 EntityList 적용

 

src/features/workflows/components/workflows.tsx

export const WorkflowsList = () => {

    const workflows = useSuspenseWorkflows();

    // if(workflows.data.items.length === 0) {
    //     return(
    //         <WorkflowsEmpty/>
    //     )
    // }

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

    return(
        <EntityList
            items={workflows.data.items}
            getKey={(workflow) => workflow.id}
            renderItem={(workflow) => <p>{workflow.name}</p>}
            emptyView={<WorkflowsEmpty/>}
        />
    )
}

 


 

5. EntityItem 컴포넌트 (개별 항목)

5-1. EntityItem 생성

 

  • 클릭 가능한 카드: 전체 카드가 Link로 감싸져 페이지 이동
  • 호버 효과: hover:shadow로 마우스 오버 시 그림자 효과
  • 삭제 중 상태: isRemoving일 때 반투명 + 클릭 비활성화
  • 이벤트 버블링 방지:
    • e.stopPropagation(): 버튼 클릭이 카드 클릭으로 전파되지 않도록
    • e.preventDefault(): 기본 동작 방지

 

src/components/entity-components.tsx

interface EntityItemProps{
    href: string;
    title: string;
    subtitle?: React.ReactNode;
    image?: React.ReactNode;
    actions?: React.ReactNode;
    onRemove?: () => void | Promise<void>;
    isRemoving?: boolean;
    className?: string;
}

export const EntityItem = ({
    href,
    title,
    subtitle,
    image,
    actions,
    onRemove,
    isRemoving,
    className,
}:EntityItemProps) => {

    const handleRemove = async (e:React.MouseEvent) => {
        e.preventDefault();
        e.stopPropagation();

        if(isRemoving){
            return;
        }

        if(onRemove) {
            return onRemove()
        }
    }
    return(
        <Link href={href} prefetch>
            <Card
                className={cn(
                    "p-4 shadow-none hover:shadow cursor-pointer",
                    isRemoving && "opacity-50 cursor-not-allowed",
                    className,
                )}
            >
                <CardContent className="flex flex-row items-center justify-between p-0">
                    <div className="flex items-center gap-3">
                        {image}
                        <div>
                            <CardTitle className="text-base font-medium">
                                {title}
                            </CardTitle>
                            {!!subtitle && (
                                <CardDescription className="text-xs">
                                    {subtitle}
                                </CardDescription>
                            )}
                        </div>
                    </div>
                    {(actions || onRemove) && (
                        <div className="flex gap-x-4 items-center">
                            {actions}
                            {onRemove && (
                                <DropdownMenu>
                                    <DropdownMenuTrigger asChild>
                                        <Button
                                            size="icon"
                                            variant="ghost"
                                            onClick={(e)=> e.stopPropagation()}                                        
                                        >
                                            <MoreVerticalIcon className="size-4" />
                                        </Button>
                                    </DropdownMenuTrigger>
                                    <DropdownMenuContent
                                        align="end"
                                        onClick={(e) => e.stopPropagation()}
                                    >
                                        <DropdownMenuItem onClick={handleRemove}>
                                            <TrashIcon className="size-4"/>
                                            Delete
                                        </DropdownMenuItem>
                                    </DropdownMenuContent>
                                </DropdownMenu>
                            )}
                        </div>
                    )}
                </CardContent>
            </Card>
        </Link>
    )
}

 

 


 

6. Superjson 설정 (날짜 직렬화)

6-1. Superjson 설치

 

💡 Superjson이 필요한 이유

tRPC는 기본적으로 JSON만 직렬화한다.

Date 객체는 JSON으로 변환 시 문자열이 되어 타입 정보를 잃게 된다.

Superjson은 Date, Map, Set 등을 올바르게 직렬화/역직렬화한다.


참고: tRPC Data Transformers

 

npm i superjson

 

 

6-2. tRPC 서버 설정

 

src/trpc/init.ts

import superjson from 'superjson'

export const createTRPCContext = cache(async () => {
  /**
   * @see: https://trpc.io/docs/server/context
   */
  return { userId: 'user_123' };
});

// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
  /**
   * @see https://trpc.io/docs/server/data-transformers
   */
  transformer: superjson,
});

 

 

6-3. Query Client 설정

 

src/trpc/query-client.ts

import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from '@tanstack/react-query';

import superjson from 'superjson';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        serializeData: superjson.serialize,
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
      hydrate: {
        deserializeData: superjson.deserialize,
      },
    },
  });
}

 

 

6-4. tRPC Client 설정

 

src/trpc/client.tsx

createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      // transformer: superjson, <-- if you use a data transformer
      transformer: superjson
      url: getUrl(),
    }),
  ],
}),

 

 

src/features/workflows/components/workflows.tsx

  • renderItem={(workflow) => <WorkflowItem data={workflow}/>}
export const WorkflowsList = () => {

    const workflows = useSuspenseWorkflows();

    return(
        <EntityList
            items={workflows.data.items}
            getKey={(workflow) => workflow.id}
            // renderItem={(workflow) => <p>{workflow.name}</p>}
            renderItem={(workflow) => <WorkflowItem data={workflow}/>}
            emptyView={<WorkflowsEmpty/>}
        />
    )
}

 

Superjson 없이 날짜 에러가 발생한 이유:

  • Superjson 없이는 Date 객체가 문자열로 전송됨
  • formatDistanceToNow는 Date 객체를 받아야 하는데 문자열을 받아 에러 발생
  • Superjson 설정 후 Date 객체가 올바르게 복원되어 해결

 

Superjson 작동 방식:

서버 (Prisma) → Date 객체
         ↓ (superjson.serialize)
    JSON + 타입 메타데이터
         ↓ (네트워크 전송)
    JSON + 타입 메타데이터
         ↓ (superjson.deserialize)
클라이언트 → Date 객체 복원

 

아직 날짜는 나오지 않는상태.

 

 


 

7. 날짜 포맷팅

 

7-1. date-fns 설치

💡 date-fns: 날짜 포맷팅을 위한 경량 라이브러리.

"3 hours ago", "2 days ago" 같은 상대 시간 표시에 유용하다.

npm i date-fn

 

 

7-2. WorkflowItem 컴포넌트 생성

 

  • formatDistanceToNow(date, { addSuffix: true }): "3 hours ago" 형식
  • &bull;: HTML 불릿 포인트 (•)
  • Superjson 덕분에 data.updatedAt과 data.createdAt이 Date 객체로 유지됨

 

import { formatDistanceToNow } from "date-fns"

// renderItem
export const WorkflowItem  = ({data} : {data: Workflow}) => {
    return(
        <EntityItem 
            href={`/workflows/${data.id}`} 
            title={data.name}
            subtitle={
                <>
                    Updated {formatDistanceToNow(data.updatedAt, { addSuffix: true})}{" "}
                    &bull; Created{" "}                    
                    Updated {formatDistanceToNow(data.createdAt, { addSuffix: true})}{" "}
                </>
            } 
            image={
                <div className="size-8 flex items-center justify-center">
                    <WorkflowIcon className="size-5 text-muted-foreground"/>
                </div>
            }  
            onRemove={()=>{}}
            isRemoving={false}     
        />
    )
}

 

 

 

 

 

8. 삭제 기능 구현

8-1. Remove Hook 생성

 

  • getMany: 목록 페이지의 데이터 새로고침
  • getOne: 상세 페이지 데이터 제거 (이미 삭제된 워크플로우)

 

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

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

    return useMutation(
        trpc.workflows.remove.mutationOptions({
            onSuccess: (data) => {
                toast.success(`Workflow "${data.name}" removed`);
                queryClient.invalidateQueries(trpc.workflows.getMany.queryOptions({}));
                queryClient.invalidateQueries(
                    trpc.workflows.getOne.queryFilter({id:data.id})
                );
            }
        })
    )
}

 

8-2. WorkflowItem에 삭제 기능 추가

 

  • MoreVertical 아이콘 (⋮) 클릭
  • 드롭다운 메뉴 표시
  • "Delete" 클릭
  • handleRemove 실행
  • API 호출 중 → isRemoving={true} (반투명 + 비활성화)
  • 성공 → 토스트 메시지 + 목록에서 제거

 

 

src/features/workflows/components/workflows.tsx

// renderItem
export const WorkflowItem  = ({data} : {data: Workflow}) => {

    const removeWorkflow = useRemoveWorkflow();

    const handleRemove = () => {
        removeWorkflow.mutate({id: data.id})
    }

    return(
        <EntityItem 
            href={`/workflows/${data.id}`} 
            title={data.name}
            subtitle={
                <>
                    Updated {formatDistanceToNow(data.updatedAt, { addSuffix: true})}{" "}
                    &bull; Created{" "}                    
                    Updated {formatDistanceToNow(data.createdAt, { addSuffix: true})}{" "}
                </>
            } 
            image={
                <div className="size-8 flex items-center justify-center">
                    <WorkflowIcon className="size-5 text-muted-foreground"/>
                </div>
            }  
            onRemove={handleRemove}
            isRemoving={removeWorkflow.isPending}     
        />
    )
}

 

반응형

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

N8N & Zapier - Editor Setup  (1) 2026.01.20
N8N & Zapier - Workflow Page  (0) 2026.01.17
N8N & Zapier - Workflows Pagination  (0) 2026.01.15
N8N & Zapier - Workflows Crud / 2  (0) 2026.01.13
N8N & Zapier - Workflows Crud / 1  (0) 2026.01.12