본문 바로가기
Clone Coding

Jira-clone - Task 생성 모달 및 폼 구현 정리

by zzuny-code 2025. 11. 5.
반응형

 

https://www.youtube.com/watch?v=37v63U7-iG0&t=4s

 

 

1. useCreateTaskModal Hook 생성

src/features/tasks/hooks/use-create-task-modal.ts

import {useQueryState, parseAsBoolean} from "nuqs"

export const useCreateTaskModal = () => {

    const [isOpen, setIsOpen] = useQueryState(
        "create-task",
        parseAsBoolean.withDefault(false).withOptions({clearOnDefault:true})
    )

    const open = () => setIsOpen(true)
    const close = () => setIsOpen(false)

    return{
        isOpen,
        open,
        close,
        setIsOpen,
    }

}

 

 

2. CreateTaskModal 컴포넌트 생성 (초기 버전)

src/features/tasks/component/create-task-modal.tsx

'use client'

import { ResponsiveModal } from "@/components/responsive-modal";
import { useCreateTaskModal } from "../hooks/use-create-task-modal"


export const CreateTaskModal = () => {
    const {isOpen, setIsOpen} = useCreateTaskModal();

    return(
        <ResponsiveModal open={isOpen} onOpenChange={setIsOpen}>
            <div>
                TODO: task form
            </div>
        </ResponsiveModal>
    )
}

 

 

3. Dashboard 레이아웃에 CreateTaskModal 추가

src/app/(dashboard)/layout.tsx

import { CreateTaskModal } from "@/features/tasks/component/create-task-modal"

const DashboardLayout = ({children}:DashboardLayoutProps) => {
    return(
        <div className='min-h-screen'>            
            <CreateTaskModal/>
            {/* ...기존 코드... */}
        </div>
    )
}

export default DashboardLayout

 

 

4. TaskViewSwitcher에 New 버튼 추가

src/features/tasks/component/task-view-switcher.tsx

import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useCreateTaskModal } from "../hooks/use-create-task-modal"

export const TaskViewSwitcher = () => {
    const {open} = useCreateTaskModal();
    
    return(
        <div>
            <Button
                onClick={open}
                size="sm"
                className="w-full lg:w-auto"
            >
                <PlusIcon className="size-4 mr-2"/>
                New
            </Button>
        </div>
    )
}

 

 

5. CreateTaskFormWrapper 컴포넌트 생성 (초기 버전)

src/features/tasks/component/create-task-form-wrapper.tsx

import { Card, CardContent } from "@/components/ui/card";
import { useGetMembers } from "@/features/members/api/use-get-members";
import { useGetProjects } from "@/features/projects/api/use-get-projects";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"
import { Loader } from "lucide-react";
import { CreateTaskForm } from "./create-task-form";

interface CreateTaskFormWrapperProps{
    onCancel: () => void
}

export const CreateTaskFormWrapper = ({
    onCancel,
}: CreateTaskFormWrapperProps) => {
    const workspaceId = useWorkspaceId();

    const {data: projects, isLoading: isLoadingProjects} = useGetProjects({workspaceId})
    const {data: members, isLoading: isLoadingMembers} = useGetMembers({workspaceId})

    const projectOptions = projects?.documents.map((project) => ({
        id: project.$id,
        name: project.name,
        imageUrl: project.imageUrl,
    }))

    const memberOptions = members?.documents.map((member) => ({
        id: member.$id,
        name: member.name,
    }))

    const isLoading = isLoadingProjects || isLoadingMembers

    if(isLoading){
        return (
            <Card className="w-full h-[714px] border-none shadow-none">
                <CardContent className="flex items-center justify-center h-full">
                    <Loader className="size-5 animate-spin text-muted-foreground"/>
                </CardContent>
            </Card>
        )
    }

    return(
        <div>
            <CreateTaskForm
                onCancel={onCancel}                
                projectOptions={projectOptions ?? []}
                memberOptions={memberOptions ?? []}
            />
        </div>
    )
}

 

 

6. CreateTaskModal 업데이트

src/features/tasks/component/create-task-modal.tsx

'use client'

import { ResponsiveModal } from "@/components/responsive-modal";
import { useCreateTaskModal } from "../hooks/use-create-task-modal"
import { CreateTaskFormWrapper } from "./create-task-form-wrapper";


export const CreateTaskModal = () => {
    const {isOpen, setIsOpen, close} = useCreateTaskModal();

    return(
        <ResponsiveModal open={isOpen} onOpenChange={setIsOpen}>
            <CreateTaskFormWrapper onCancel={close}/>
        </ResponsiveModal>
    )
}

 

 

 

7. useCreateTask Hook 생성

src/features/tasks/api/use-create-task.ts

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.tasks['$post'], 200>
type RequestType = InferRequestType<typeof client.api.tasks['$post']>

export const useCreateTask = () => {

    const queryClient = useQueryClient()

    const mutation = useMutation<
        ResponseType,
        Error,
        RequestType
    >({
        mutationFn: async({json}) => {
            const response = await client.api.tasks["$post"]({json});
            
            if(!response.ok){
                throw new Error("Failed to create task")
            }

            return response.json()
        },
        onSuccess: () => {
            toast.success('task created')
            queryClient.invalidateQueries({queryKey : ['tasks']})
        },
        onError: ()=> {
            toast.error("Failed to create task")
        }
    })
    return mutation
}

 

 

8. useGetTasks Hook 생성

src/features/tasks/api/use-get-task.ts

import {useQuery} from "@tanstack/react-query"

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

interface useGetTasksProps{
    workspaceId: string;
}

export const useGetTasks = ({workspaceId}:useGetTasksProps) => {
    const query = useQuery({
        queryKey:["tasks", workspaceId],
        queryFn: async () => {
            const response = await client.api.tasks.$get({
                query: {workspaceId}
            });

            if(!response.ok){
                throw new Error("Failted to fetch task")
            }

            const {data} = await response.json();

            return data;
        }
    })

    return query
}

 

 

9. CreateTaskForm 컴포넌트 생성 (초기 버전)

src/features/tasks/component/create-task-form.tsx

'use client'

import z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";


import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DottedSeparator from "@/components/dottedSeparator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";

import { createTaskSchema } from "../schemas";
import { useCreateTask } from "../api/use-create-task";


interface CreateTaskFormProps{
    onCancel?: () => void;
    projectOptions: {id:string, name: string, imageUrl: string}[];
    memberOptions: {id:string, name: string}[];
}
 
export const CreateTaskForm = ({onCancel, projectOptions, memberOptions} : CreateTaskFormProps) =>{

    const workspaceId = useWorkspaceId()
    // const router = useRouter();
    const {mutate, isPending} = useCreateTask();

    const form = useForm<z.infer<typeof createTaskSchema>>({
        resolver: zodResolver(createTaskSchema.omit({workspaceId: true})),
        defaultValues:{
            workspaceId,
        }
    })

    const onSubmit = (values: z.infer<typeof createTaskSchema>) => {
        mutate({json:{...values, workspaceId}}, {
            onSuccess:()=>{
                form.reset();
                // TODO: Redirect to new task
            }
        })
    }

    return(
        <Card className="w-full h-full border-none shadow-none">
            <CardHeader className="flex p-7">
                <CardTitle className="text-xl font-bold">
                    Create a new Task
                </CardTitle>
            </CardHeader>
            <div className="px=7">
                <DottedSeparator/>
            </div>
            <CardContent className="p-7">
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)}>
                        <div className="flex flex-col gap-y-4">
                            <FormField
                                control={form.control}
                                name="name"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Task Name</FormLabel>
                                        <FormControl>
                                            <Input
                                                {...field}
                                                placeholder="Enter task name"
                                            />
                                        </FormControl>
                                        <FormMessage/>
                                    </FormItem>
                                )}
                            />
                            <FormField
                                control={form.control}
                                name="dueDate"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Due Date</FormLabel>
                                        <FormControl>
                                            {/* TODO : Date Picker */}
                                        </FormControl>
                                        <FormMessage/>
                                    </FormItem>
                                )}
                            />
                        </div>
                        <DottedSeparator className="py-7"/>
                        <div className="flex items-center justify-between">
                            <Button
                                type="button"
                                size="lg"
                                variant='secondary'
                                onClick={onCancel}
                                disabled={isPending}
                                className={cn(!onCancel && "invisible")}
                                // onCancel prop이 전달되지 않으면 버튼을 invisible 처리하여 레이아웃은 유지하되 시각적으로 숨김
                            >
                                Cancel
                            </Button>
                            <Button
                                type="submit"
                                size="lg"
                                disabled={isPending}
                            >
                                Create Task
                            </Button>
                        </div>
                    </form>
                </Form>
            </CardContent>
        </Card>
    )
}

 

 

10. CreateTaskFormWrapper 업데이트

src/features/tasks/component/create-task-form-wrapper.tsx

import { Card, CardContent } from "@/components/ui/card";
import { useGetMembers } from "@/features/members/api/use-get-members";
import { useGetProjects } from "@/features/projects/api/use-get-projects";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"
import { Loader } from "lucide-react";
import { CreateTaskForm } from "./create-task-form";

interface CreateTaskFormWrapperProps{
    onCancel: () => void
}

export const CreateTaskFormWrapper = ({
    onCancel,
}: CreateTaskFormWrapperProps) => {
    const workspaceId = useWorkspaceId();

    const {data: projects, isLoading: isLoadingProjects} = useGetProjects({workspaceId})
    const {data: members, isLoading: isLoadingMembers} = useGetMembers({workspaceId})

    const projectOptions = projects?.documents.map((project) => ({
        id: project.$id,
        name: project.name,
        imageUrl: project.imageUrl,
    }))

    const memberOptions = members?.documents.map((project) => ({
        id: project.$id,
        name: project.name,
    }))

    const isLoading = isLoadingProjects || isLoadingMembers

    if(isLoading){
        return (
            <Card className="w-full h-[714px] border-none shadow-none">
                <CardContent className="flex items-center justify-center h-full">
                    <Loader className="size-5 animate-spin text-muted-foreground"/>
                </CardContent>
            </Card>
        )
    }

    return(
        <div>
            <CreateTaskForm
                onCancel={onCancel}                
                projectOptions={projectOptions ?? []}
                memberOptions={memberOptions ?? []}
            />
        </div>
    )
}

 

 

11. date-fns 설치

bun add date-fns

// bun add date-fns@4.1.0

 

 

 

12. DatePicker 컴포넌트 생성

src/components/date-picker.tsx

import * as React from 'react'
import {format} from 'date-fns'
import { Calendar as CalendarIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { Calendar } from "./ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";


interface DatePickerProps{
    value: Date | undefined;
    onChange: (date:Date) => void;
    className?: string;
    placeholder?: string;
}

export const DatePicker = ({value, onChange, className, placeholder="Select date"}:DatePickerProps) => {
    return(
        <Popover>
            <PopoverTrigger asChild>
                <Button
                    variant='outline'
                    size='lg'
                    className={cn(
                        "w-full justify-start text-left font-normal px-3",
                        !value && "text-muted-foreground",
                        className
                    )}
                >
                    <CalendarIcon className="mr-2 h-4 w-4"/>
                    {value ? format(value, "PPP") : <span>{placeholder}</span>}
                </Button>
            </PopoverTrigger>
            <PopoverContent className='w-auto p-0'>
                <Calendar
                    mode='single'
                    selected={value}
                    onSelect={(date)=> onChange(date as Date)}
                    autoFocus
                />
            </PopoverContent>
        </Popover>
    )
}

 

 

 

13. CreateTaskForm 완성 버전

 

src/features/tasks/component/create-task-form.tsx  적용하고 나머지 form들도 추가해줌

'use client'

import z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";


import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DottedSeparator from "@/components/dottedSeparator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";


import { TaskStatus } from "../types";
import { createTaskSchema } from "../schemas";
import { useCreateTask } from "../api/use-create-task";
import { DatePicker } from "@/components/date-picker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MemberAvatar } from "@/features/members/components/members-avatar";
import { ProjectAvatar } from "@/features/projects/components/project-avatar";


interface CreateTaskFormProps{
    onCancel?: () => void;
    projectOptions: {id:string, name: string, imageUrl: string}[];
    memberOptions: {id:string, name: string}[];
}
 
export const CreateTaskForm = ({onCancel, projectOptions, memberOptions} : CreateTaskFormProps) =>{

    const workspaceId = useWorkspaceId()
    // const router = useRouter();
    const {mutate, isPending} = useCreateTask();

    const form = useForm<z.infer<typeof createTaskSchema>>({
        resolver: zodResolver(createTaskSchema.omit({workspaceId: true})),
        defaultValues:{
            workspaceId,
        }
    })

    const onSubmit = (values: z.infer<typeof createTaskSchema>) => {
        mutate({json:{...values, workspaceId}}, {
            onSuccess:()=>{
                form.reset();
                onCancel?.()
            }
        })
    }

    return(
        <Card className="w-full h-full border-none shadow-none">
            <CardHeader className="flex p-7">
                <CardTitle className="text-xl font-bold">
                    Create a new Task
                </CardTitle>
            </CardHeader>
            <div className="px=7">
                <DottedSeparator/>
            </div>
            <CardContent className="p-7">
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)}>
                        <div className="flex flex-col gap-y-4">
                            <FormField
                                control={form.control}
                                name="name"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Task Name</FormLabel>
                                        <FormControl>
                                            <Input
                                                {...field}
                                                placeholder="Enter Project name"
                                            />
                                        </FormControl>
                                        <FormMessage/>
                                    </FormItem>
                                )}
                            />
                            <FormField
                                control={form.control}
                                name="dueDate"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Due Date</FormLabel>
                                        <FormControl>
                                            <DatePicker {...field}/>
                                        </FormControl>
                                        <FormMessage/>
                                    </FormItem>
                                )}
                            />
                            <FormField
                                control={form.control}
                                name="assigneeId"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Assignee</FormLabel>
                                        <Select
                                            defaultValue={field.value}
                                            onValueChange={field.onChange}
                                        >
                                            <FormControl>
                                                <SelectTrigger>
                                                    <SelectValue placeholder="Select assignee"/>
                                                </SelectTrigger>
                                            </FormControl>
                                            <FormMessage/>
                                            <SelectContent>
                                                {memberOptions.map((member) => (
                                                    <SelectItem key={member.id} value={member.id}>
                                                        <div className="flex items-center gap-x-2">
                                                            <MemberAvatar
                                                                className="size-6"
                                                                name={member.name}
                                                            />
                                                            {member.name}
                                                        </div>
                                                    </SelectItem>
                                                ))}
                                            </SelectContent>
                                        </Select>
                                    </FormItem>
                                )}
                            />
                            <FormField
                                control={form.control}
                                name="status"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Status</FormLabel>
                                        <Select
                                            defaultValue={field.value}
                                            onValueChange={field.onChange}
                                        >
                                            <FormControl>
                                                <SelectTrigger>
                                                    <SelectValue placeholder="Select status"/>
                                                </SelectTrigger>
                                            </FormControl>
                                            <FormMessage/>
                                            <SelectContent>
                                                <SelectItem value={TaskStatus.BACKLOT}>
                                                    Backlog
                                                </SelectItem>
                                                <SelectItem value={TaskStatus.IN_PROGRESS}>
                                                    In Progress
                                                </SelectItem>
                                                <SelectItem value={TaskStatus.IN_PREVIEW}>
                                                    In Preview
                                                </SelectItem>
                                                <SelectItem value={TaskStatus.TODO}>
                                                    Todo
                                                </SelectItem>
                                                <SelectItem value={TaskStatus.DONE}>
                                                    Done
                                                </SelectItem>
                                            </SelectContent>
                                        </Select>
                                    </FormItem>
                                )}
                            />

                            <FormField
                                control={form.control}
                                name="projectId"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Project</FormLabel>
                                        <Select
                                            defaultValue={field.value}
                                            onValueChange={field.onChange}
                                        >
                                            <FormControl>
                                                <SelectTrigger>
                                                    <SelectValue placeholder="Select Project"/>
                                                </SelectTrigger>
                                            </FormControl>
                                            <FormMessage/>
                                            <SelectContent>
                                                {projectOptions.map((project) => (
                                                    <SelectItem key={project.id} value={project.id}>
                                                        <div className="flex items-center gap-x-2">
                                                            <ProjectAvatar
                                                                className="size-6"
                                                                name={project.name}
                                                                image={project.imageUrl}
                                                            />
                                                            {project.name}
                                                        </div>
                                                    </SelectItem>
                                                ))}
                                            </SelectContent>
                                        </Select>
                                    </FormItem>
                                )}
                            />
                        </div>
                        <DottedSeparator className="py-7"/>
                        <div className="flex items-center justify-between">
                            <Button
                                type="button"
                                size="lg"
                                variant='secondary'
                                onClick={onCancel}
                                disabled={isPending}
                                className={cn(!onCancel && "invisible")}
                            >
                                Cancel
                            </Button>
                            <Button
                                type="submit"
                                size="lg"
                                disabled={isPending}
                            >
                                Create Task
                            </Button>
                        </div>
                    </form>
                </Form>
            </CardContent>
        </Card>
    )
}

 

 

14. Appwrite Tasks 테이블 권한 설정

appwrite -> tasks -> setting -> Permissions -> users 에 권한 다주기 

 

 

15. Task 생성 테스트

"Create New Task" 버튼 클릭 → 폼 작성 → 제출 → Appwrite에서 데이터 확인

 

 

 

 

반응형