본문 바로가기
Clone Coding

Jira-clone - 워크스페이스 수정(Update) 기능 구현

by zzuny-code 2025. 10. 10.
반응형

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

06:22:54

 

1. 워크스페이스 수정 스키마 정의 (src/features/workspaces/schemas.ts)

  • updateWorkspaceSchema 생성
  • optional(): 필드를 선택적으로 만들어 부분 업데이트 가능
  • transform: 빈 문자열을 undefined로 변환하여 이미지 제거 처리
export const updateWorkspaceSchema = z.object({
    name: z.string().trim().min(1, "Must be 1 or more characters").optional(),
    image : z.union([
        z.instanceof(File),
        z.string().transform((value) => value === "" ? undefined : value),
    ])
    .optional(),
})

 

2. 멤버 권한 확인 유틸리티(src/features/members/utils.ts)

  • getMember : 워크스페이스를 수정하기 전에 현재 사용자가 해당 워크스페이스의 멤버인지, 그리고 ADMIN 권한이 있는지 확인
  • 특정 워크스페이스 + 사용자 ID로 멤버 문서 조회
  • 첫 번째 문서 반환 (조건에 맞는 멤버는 1명만 존재)
import { DATABASE_ID, MEMBERS_ID } from "@/config";
import { Query, type Databases } from "node-appwrite";


interface GetMemberProps{
    databases: Databases;
    workspaceId: string;
    userId: string
};

export const getMember = async ({
    databases,
    workspaceId,
    userId
}:GetMemberProps) => {
    const members = await databases.listDocuments(
        DATABASE_ID,
        MEMBERS_ID,
        [
            Query.equal("workspaceId", workspaceId),
            Query.equal("userId", userId)
        ]
    )

    return members.documents[0]
}

 

 

3. PATCH API 엔드포인트 구현 ( src/features/workspaces/server/route.ts )

 

  • 권한 검증: ADMIN이 아니면 401 에러 반환
  • 이미지 처리: 새 파일이면 업로드, 기존 ID면 그대로 사용
  • 문서 업데이트: 변경된 필드만 업데이트

 

.patch(
    "/:workspaceId",
    sessionMiddleware,
    zValidator("form", updateWorkspaceSchema),
    async (c) => {
        const databases = c.get("databases");
        const storage = c.get("storage");
        const user = c.get("user");

        const {workspaceId} = c.req.param();
        const {name, image} = c.req.valid("form");

        const member = await getMember({
            databases,
            workspaceId,
            userId:user.$id
        })

        if(!member || member.role !== MemberRole.ADMIN){
            return c.json({ error: "Unauthorized"}, 401)
        }

        let uploadedImageUrl : string | undefined

        if(image instanceof File){

            const file = await storage.createFile(
                IMAGES_BUCKET_ID,
                ID.unique(),
                image
            );

            uploadedImageUrl = file.$id;

        }else{
            uploadedImageUrl = image
        }

        const workspace = await databases.updateDocument(
            DATABASE_ID,
            WORKSPACES_ID,
            workspaceId,
            // {
            //     name,
            //     imageUrl: uploadedImageUrl,
            // }
            {
                ...(name && { name }), // name이 있을 때만 포함
                ...(uploadedImageUrl && { imageUrl: uploadedImageUrl }), // imageUrl이 있을 때만 포함
            }
        );

        return c.json({data:workspace})
    }
)

 

 

4. 클라이언트 훅 - useUpdateWorkspace( src/features/workspaces/api/use-update-workspace.ts )

 

  • React Query를 사용한 mutation 훅으로, 워크스페이스 수정 요청을 처리
  • 쿼리 무효화: 업데이트 후 전체 워크스페이스 목록 + 특정 워크스페이스 캐시 갱신
  • 토스트 알림: 성공/실패 메시지 표시

 

import { toast } from 'sonner';
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { InferRequestType, InferResponseType } from 'hono';

import {client} from '@/lib/rpc'

type ResponseType = InferResponseType<typeof client.api.workspaces[':workspaceId']['$patch'], 200>
type RequestType = InferRequestType<typeof client.api.workspaces[':workspaceId']['$patch']>

export const useUpdateWorkspace = () => {

    const queryClient = useQueryClient()

    const mutation = useMutation<
        ResponseType,
        Error,
        RequestType
    >({
        mutationFn: async({form, param}) => {
            const response = await client.api.workspaces[':workspaceId']['$patch']({form, param});

            console.log(response);
            

            if(!response.ok){
                throw new Error("Failed to update workspace")
            }

            return response.json()
        },
        onSuccess: ({data}) => {
            toast.success('Workspace updated')
            queryClient.invalidateQueries({queryKey : ['workspaces']})
            queryClient.invalidateQueries({queryKey : ['workspaces', data.$id]})
        },
        onError: ()=> {
            toast.error("Failed to update workspace")
        }
    })
    return mutation
}

 

 

5. 워크스페이스 설정 페이지 ( src/components/navigation.tsx )

  • Settings 페이지 라우팅 : Navigation - Settings 메뉴로 사용자가 대시보드에서 설정 페이지로 쉽게 이동할 수 있도록 함
"use client"

import Link from 'next/link';
import {SettingsIcon, UsersIcon} from 'lucide-react';
import {GoCheckCircle, GoCheckCircleFill, GoHome, GoHomeFill} from 'react-icons/go';


import { useWorkspaceId } from '@/features/workspaces/hooks/use-workspace-id';
import { cn } from '@/lib/utils';
import { usePathname } from 'next/navigation';

const routes = [
    // ...
    {
        label: "Settings",
        href: "/settings",
        icon: SettingsIcon,
        activeIcon: SettingsIcon,
    },
    // ...
]

export const Navigation = () => {

    const workspaceId = useWorkspaceId();
    const pathname = usePathname();

    return(
        <div className='flex flex-col'>
            {routes.map((item) => {
                const fullHref = `/workspaces/${workspaceId}${item.href}`
                const isActive = pathname === fullHref;
                const Icon = isActive ? item.activeIcon : item.icon

                return (
                    <Link key={item.href} href={fullHref}>
                        <div className={cn(
                            "flex items-center gap-2.5 p-2.5 rounded-md font-medium hover:text-primary transition text-neutral-500",
                            isActive && "bg-white shadow-sm hover:opacity-100 text-primary"
                        )}>
                            <Icon className='size-5 text-neutral-500'/>
                            {item.label}
                        </div>
                    </Link>
                )
            })}
        </div>
    )
}

 

5-1. 서버 액션 - getWorkspace ( src/features/workspaces/actions.ts )

  • 설정 페이지에서 기존 워크스페이스 데이터를 불러오는 서버 액션
  • 기존 listDocuments 대신 getMember 유틸리티 사용으로 코드 간결화
  • 멤버가 아니면 null 반환하여 접근 차단
//src/features/workspaces/type.ts

import {Models} from "node-appwrite";

export type Workspace = Models.Document & {
    name: string;
    imageUrl: string;
    inviteCode: string;
    userId: string;
}

 

"use server"

import { cookies } from 'next/headers'
import { Client, Databases, Query, Account } from 'node-appwrite'

import { AUTH_COOKIE } from '@/features/auth/constants'
import { DATABASE_ID, MEMBERS_ID, WORKSPACES_ID } from '@/config'
import { Workspace } from './type'
import { getMember } from '../members/utils'

...

interface GetWorkspaceProps{
    workspaceId:string
}

export const getWorkspace = async ({workspaceId}:GetWorkspaceProps) => {
    try{
        const client = new Client()
            .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
            .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT!)

        const session = await cookies().get(AUTH_COOKIE)

        if(!session) return null

        client.setSession(session.value);
        const databases = new Databases(client);
        const account = new Account(client);
        
        // 1. 사용자 인증 확인
        const user = await account.get();

        // 2. 멤버십 확인
        const member = await getMember({
            databases,
            userId: user.$id,
            workspaceId
        })

        if(!member) return null

        // 3. 워크스페이스 데이터 조회
        const workspace = await databases.getDocument<Workspace>(
            DATABASE_ID,
            WORKSPACES_ID,
            workspaceId
        );

        return workspace

    }catch{
        return null
    }
}

 

5-2. Settings 페이지 컴포넌트 ( src/app/(standalone)/workspaces/[workspaceId]/settings/page.tsx )

import { getCurrent } from "@/features/auth/actions";
import { getWorkspace } from "@/features/workspaces/actions";
import { EditWorkspaceForm } from "@/features/workspaces/component/edit-workspace-form";
import { redirect } from "next/navigation";

interface WorkspaceIdSettingsPageProps{
    params:{
        workspaceId:string
    }
}


const WorkspaceIdSettingsPage = async ({params}:WorkspaceIdSettingsPageProps) => {

    const user = await getCurrent();
    if(!user) redirect('sign-in');

    const initialValues = await getWorkspace({workspaceId: params.workspaceId})

    if(!initialValues){
        redirect(`/workspaces/${params.workspaceId}`)
    }


    return(
        <div className="w-full lg:max-w-xl">
            {/* WorkspaceIdSettingsPage : {params.workspaceId} */}
            <EditWorkspaceForm initialValues={initialValues}/>
        </div>
    )
}

export default WorkspaceIdSettingsPage

 

5_3. Form 컴포넌트 ( src/features/workspaces/component/edit-workspace-form.tsx )

  • Appwrite는 이미지를 저장할 때 파일 ID만 반환하기 때문에,
  • Next.js Image 컴포넌트에서 사용하려면 전체 URL을 직접 생성해야함
'use client'

import { useRef } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { updateWorkspaceSchema } from "../schemas";
import { useForm } from "react-hook-form";
import z from "zod";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useUpdateWorkspace } from "../api/use-update-workspace";
import DottedSeparator from "@/components/dottedSeparator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import Image from "next/image";
import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Workspace } from "../type";

interface EditWorkspaceFormProps{
    onCancel?: () => void;
    initialValues:Workspace;
}

export const EditWorkspaceForm = ({onCancel, initialValues} : EditWorkspaceFormProps) =>{

    const router = useRouter();

    const {mutate, isPending} = useUpdateWorkspace();

    const inputRef = useRef<HTMLInputElement>(null)

    const form = useForm<z.infer<typeof updateWorkspaceSchema>>({
        resolver: zodResolver(updateWorkspaceSchema),
        defaultValues:{
            ...initialValues,
            image: initialValues.imageUrl ?? ""
        }
    })

    const onSubmit = (values: z.infer<typeof updateWorkspaceSchema>) => {
        
        const finalValues = {
            ...values,
            // image: values.image instanceof File ? values.image : undefined,
            image: values.image instanceof File 
            ? values.image 
            : values.image && values.image !== "" 
                ? values.image // 기존 이미지 ID 유지
                : "", // 이미지 제거
        };
        
        mutate({
            form:finalValues,
            param: {workspaceId: initialValues.$id}

        }, {
            onSuccess:({data})=>{
                form.reset();
                router.push(`/workspaces/${data.$id}`)
            }
        })
    }

    const handleImageChange = (e:React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if(file){
            form.setValue("image", file)
        } 
    }


    return(
        ...
        
        <Image
            alt="Logo"
            fill
            className="object-cover"
            src={
                field.value instanceof File
                    ? URL.createObjectURL(field.value)  // 새로 선택한 파일
                    : field.value ? 
                        // Appwrite 이미지 ID → 완전한 URL 변환
                        `${APPWRITE_ENDPOINT}/storage/buckets/${IMAGES_BUCKET_ID}/files/${field.value}/view?project=${PROJECT_ID}`
                        : ''  // 이미지 없을 때
            }
        />
    )
}

 

http://localhost:3000/workspaces/[workspacesId]/settings

 

 

6. Back 버튼 조건부 동작 ( src/features/workspaces/components/edit-workspace-form.tsx )

 

  • onCancel prop이 있으면 → 모달 닫기
  • onCancel이 없으면 → 워크스페이스 홈으로 이동

 

<CardHeader className="flex flex-row items-center gap-x-4 p-7 space-y-0">
    <Button 
        size='sm' 
        variant="secondary" 
        onClick={onCancel? 
        onCancel : ()=> router.push(`/workspaces/${initialValues.$id}`)}
    >
        <ArrowLeftIcon className="size-4 mr-2"/>
        Back
    </Button>
    <CardTitle className="text-xl font-bold">
        {initialValues.name}
    </CardTitle>
</CardHeader>

 

 

 

7. 이미지 제거 기능 ( Remove Image 버튼 추가 ) 

src/features/workspaces/components/create-workspace-form.tsx

src/features/workspaces/component/edit-workspace-form.tsx

{field.value ? (
    <Button
        type="button"
        disabled={isPending}
        variant="tertiary"
        size="xs"
        className="w-fit mt-2"
        onClick={()=>inputRef.current?.click()}
    >
        Remove Image
    </Button>
):(
    <Button
        type="button"
        disabled={isPending}
        variant="tertiary"
        size="xs"
        className="w-fit mt-2"
        onClick={()=>inputRef.current?.click()}
    >
        Upload Image
    </Button>
)}

 

 

8. API Route 설정 ( src/app/api/[[...route]]/route.ts )

import {Hono} from 'hono'
import {handle} from 'hono/vercel'

import auth from '@/features/auth/server/route'
import workspaces from '@/features/workspaces/server/route'

const app = new Hono().basePath("/api");

const routes = app
    .route("/auth", auth)
    .route("/workspaces", workspaces)



export const GET = handle(routes)
export const POST = handle(routes)
export const PATCH = handle(routes)  // PATCH 메서드 추가

// 🔥 RPC 타입 - 클라이언트에서 타입 안전하게 API 호출 가능
export type AppType = typeof routes

 

 

* 수정하는 부분에서 이름만 바꿔주면 이미지가 undefined가 전송되서 imageUrl이 사라졌다.

* 서버와 클라이언트 측 코드를 수정해주고 해결했다.

 

반응형