diff --git a/src/components/modal/task/ModalTaskForm.tsx b/src/components/modal/task/ModalTaskForm.tsx index 74d04d48..164c2524 100644 --- a/src/components/modal/task/ModalTaskForm.tsx +++ b/src/components/modal/task/ModalTaskForm.tsx @@ -14,7 +14,7 @@ import CustomMarkdown from '@components/common/CustomMarkdown'; import DuplicationCheckInput from '@components/common/DuplicationCheckInput'; import useToast from '@hooks/useToast'; import useAxios from '@hooks/useAxios'; -import { useTasksQuery } from '@hooks/query/useTaskQuery'; +import { useReadStatusTasks } from '@hooks/query/useTaskQuery'; import { useReadStatuses } from '@hooks/query/useStatusQuery'; import { convertBytesToString } from '@utils/converter'; import { findUserByProject } from '@services/projectService'; @@ -45,7 +45,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod const [files, setFiles] = useState([]); const { statusList, isStatusLoading } = useReadStatuses(projectId, taskId); - const { taskNameList } = useTasksQuery(projectId); + const { taskNameList } = useReadStatusTasks(projectId); const { data, loading, clearData, fetchData } = useAxios(findUserByProject); const { toastInfo, toastWarn } = useToast(); diff --git a/src/hooks/query/useStatusQuery.ts b/src/hooks/query/useStatusQuery.ts index 6578cf3d..8f9c648f 100644 --- a/src/hooks/query/useStatusQuery.ts +++ b/src/hooks/query/useStatusQuery.ts @@ -102,7 +102,7 @@ export function useCreateStatus(projectId: Project['projectId']) { onSuccess: () => { toastSuccess('프로젝트 상태를 추가하였습니다.'); queryClient.invalidateQueries({ - queryKey: ['projects', projectId, 'statuses'], + queryKey: ['projects', projectId], }); }, }); @@ -119,7 +119,7 @@ export function useUpdateStatus(projectId: Project['projectId'], statusId: Proje onSuccess: () => { toastSuccess('프로젝트 상태를 수정했습니다.'); queryClient.invalidateQueries({ - queryKey: ['projects', projectId, 'statuses'], + queryKey: ['projects', projectId], }); }, }); diff --git a/src/hooks/query/useTaskQuery.ts b/src/hooks/query/useTaskQuery.ts index eb06ca03..be7a5547 100644 --- a/src/hooks/query/useTaskQuery.ts +++ b/src/hooks/query/useTaskQuery.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; -import { findTaskList } from '@services/taskService'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { findTaskList, updateTaskOrder } from '@services/taskService'; +import useToast from '@hooks/useToast'; -import type { TaskListWithStatus } from '@/types/TaskType'; +import type { TaskListWithStatus, TaskOrder } from '@/types/TaskType'; import type { Project } from '@/types/ProjectType'; function getTaskNameList(taskList: TaskListWithStatus[]) { @@ -14,9 +15,9 @@ function getTaskNameList(taskList: TaskListWithStatus[]) { } // Todo: Task Query CUD로직 작성하기 -export function useTasksQuery(projectId: Project['projectId']) { +export function useReadStatusTasks(projectId: Project['projectId']) { const { - data: taskList = [], + data: statusTaskList = [], isLoading: isTaskLoading, isError: isTaskError, error: taskError, @@ -28,7 +29,39 @@ export function useTasksQuery(projectId: Project['projectId']) { }, }); - const taskNameList = getTaskNameList(taskList); + const taskNameList = getTaskNameList(statusTaskList); - return { taskList, taskNameList, isTaskLoading, isTaskError, taskError }; + return { statusTaskList, taskNameList, isTaskLoading, isTaskError, taskError }; +} + +export function useUpdateTasksOrder(projectId: Project['projectId']) { + const { toastError } = useToast(); + const queryClient = useQueryClient(); + const queryKey = ['projects', projectId, 'tasks']; + + const mutation = useMutation({ + mutationFn: (newStatusTaskList: TaskListWithStatus[]) => { + const taskOrders: TaskOrder[] = newStatusTaskList + .map((statusTask) => { + const { statusId, tasks } = statusTask; + return tasks.map(({ taskId, sortOrder }) => ({ statusId, taskId, sortOrder })); + }) + .flat(); + return updateTaskOrder(projectId, { tasks: taskOrders }); + }, + onMutate: async (newStatusTaskList: TaskListWithStatus[]) => { + await queryClient.cancelQueries({ queryKey }); + + const previousStatusTaskList = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, newStatusTaskList); + + return { previousStatusTaskList }; + }, + onError: (err, newStatusTaskList, context) => { + toastError('일정 순서 변경에 실패 하였습니다. 잠시후 다시 진행해주세요.'); + queryClient.setQueryData(queryKey, context?.previousStatusTaskList); + }, + }); + + return mutation; } diff --git a/src/mocks/mockHash.ts b/src/mocks/mockHash.ts new file mode 100644 index 00000000..740828ca --- /dev/null +++ b/src/mocks/mockHash.ts @@ -0,0 +1,30 @@ +import { PROJECT_DUMMY, ROLE_DUMMY, STATUS_DUMMY, TASK_DUMMY, TEAM_DUMMY, USER_DUMMY } from '@mocks/mockData'; +import type { User } from '@/types/UserType'; +import type { Role } from '@/types/RoleType'; +import type { Team } from '@/types/TeamType'; +import type { Project } from '@/types/ProjectType'; +import type { ProjectStatus } from '@/types/ProjectStatusType'; +import type { Task } from '@/types/TaskType'; + +type Hash = { + [key: string | number]: T; +}; + +// ToDo: MSW 처리중 해쉬 테이블 처리를 사용하는 부분 대체해주기 +export const USERS_HASH: Hash = {}; +USER_DUMMY.forEach((user) => (USERS_HASH[user.userId] = user)); + +export const ROLES_HASH: Hash = {}; +ROLE_DUMMY.forEach((role) => (ROLES_HASH[role.roleId] = role)); + +export const TEAMS_HASH: Hash = {}; +TEAM_DUMMY.forEach((team) => (TEAMS_HASH[team.teamId] = team)); + +export const PROJECTS_HASH: Hash = {}; +PROJECT_DUMMY.forEach((project) => (PROJECTS_HASH[project.projectId] = project)); + +export const STATUSES_HASH: Hash = {}; +STATUS_DUMMY.forEach((status) => (STATUSES_HASH[status.statusId] = status)); + +export const TASK_HASH: Hash = {}; +TASK_DUMMY.forEach((task) => (TASK_HASH[task.taskId] = task)); diff --git a/src/mocks/services/taskServiceHandler.ts b/src/mocks/services/taskServiceHandler.ts index 27bfe9f4..ae7f26d3 100644 --- a/src/mocks/services/taskServiceHandler.ts +++ b/src/mocks/services/taskServiceHandler.ts @@ -1,9 +1,12 @@ import { http, HttpResponse } from 'msw'; import { STATUS_DUMMY, TASK_DUMMY } from '@mocks/mockData'; +import { STATUSES_HASH, TASK_HASH } from '@mocks/mockHash'; +import type { TaskOrderForm } from '@/types/TaskType'; const BASE_URL = import.meta.env.VITE_BASE_URL; const taskServiceHandler = [ + // 일정 목록 조회 API http.get(`${BASE_URL}/project/:projectId/task`, ({ request, params }) => { const accessToken = request.headers.get('Authorization'); const { projectId } = params; @@ -12,12 +15,36 @@ const taskServiceHandler = [ const statusList = STATUS_DUMMY.filter((status) => status.projectId === Number(projectId)); const statusTaskList = statusList.map((status) => { - const tasks = TASK_DUMMY.filter((task) => task.statusId === status.statusId); + const tasks = TASK_DUMMY.filter((task) => task.statusId === status.statusId).sort( + (a, b) => a.sortOrder - b.sortOrder, + ); return { ...status, tasks }; }); return HttpResponse.json(statusTaskList); }), + // 일정 순서 변경 API + http.patch(`${BASE_URL}/project/:projectId/task/order`, async ({ request, params }) => { + const accessToken = request.headers.get('Authorization'); + const { tasks: taskOrders } = (await request.json()) as TaskOrderForm; + const { projectId } = params; + + if (!accessToken) return new HttpResponse(null, { status: 401 }); + + for (let i = 0; i < taskOrders.length; i++) { + const { taskId, statusId, sortOrder } = taskOrders[i]; + + const target = TASK_HASH[taskId]; + if (!target) return new HttpResponse(null, { status: 404 }); + + const status = STATUSES_HASH[statusId]; + if (status.projectId !== Number(projectId)) return new HttpResponse(null, { status: 400 }); + + target.statusId = statusId; + target.sortOrder = sortOrder; + } + return new HttpResponse(null, { status: 204 }); + }), ]; export default taskServiceHandler; diff --git a/src/pages/project/CalendarPage.tsx b/src/pages/project/CalendarPage.tsx index f28293c8..d54e48ab 100644 --- a/src/pages/project/CalendarPage.tsx +++ b/src/pages/project/CalendarPage.tsx @@ -7,12 +7,12 @@ import CustomEventWrapper from '@components/task/calendar/CustomEventWrapper'; import UpdateModalTask from '@components/modal/task/UpdateModalTask'; import useModal from '@hooks/useModal'; import useProjectContext from '@hooks/useProjectContext'; +import { useReadStatusTasks } from '@hooks/query/useTaskQuery'; import Validator from '@utils/Validator'; import { TaskListWithStatus, TaskWithStatus } from '@/types/TaskType'; import { CustomEvent } from '@/types/CustomEventType'; import 'react-big-calendar/lib/css/react-big-calendar.css'; import '@/customReactBigCalendar.css'; -import { useTasksQuery } from '@/hooks/query/useTaskQuery'; function getCalendarTask(statusTasks: TaskListWithStatus[]) { const calendarTasks: TaskWithStatus[] = []; @@ -38,7 +38,7 @@ export default function CalendarPage() { const { showModal, openModal, closeModal } = useModal(); const [selectedTask, setSelectedTask] = useState(); const [date, setDate] = useState(() => DateTime.now().toJSDate()); - const { taskList, isTaskLoading, isTaskError, taskError } = useTasksQuery(project.projectId); + const { statusTaskList, isTaskLoading, isTaskError, taskError } = useReadStatusTasks(project.projectId); const handleNavigate = useCallback((newDate: Date) => setDate(newDate), [setDate]); @@ -77,7 +77,7 @@ export default function CalendarPage() { ); const state = { - events: getCalendarTask(taskList) + events: getCalendarTask(statusTaskList) .map((task) => ({ title: task.name, start: new Date(task.startDate), diff --git a/src/pages/project/KanbanPage.tsx b/src/pages/project/KanbanPage.tsx index 71d30a0e..27196be2 100644 --- a/src/pages/project/KanbanPage.tsx +++ b/src/pages/project/KanbanPage.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; -import ProjectStatusContainer from '@components/task/kanban/ProjectStatusContainer'; import { DND_DROPPABLE_PREFIX, DND_TYPE } from '@constants/dnd'; +import ProjectStatusContainer from '@components/task/kanban/ProjectStatusContainer'; import deepClone from '@utils/deepClone'; import { parsePrefixId } from '@utils/converter'; -import { TASK_SPECIAL_DUMMY } from '@mocks/mockData'; +import useProjectContext from '@hooks/useProjectContext'; +import { useReadStatusTasks, useUpdateTasksOrder } from '@hooks/query/useTaskQuery'; import type { Task, TaskListWithStatus } from '@/types/TaskType'; function createChangedStatus(statusTasks: TaskListWithStatus[], dropResult: DropResult) { @@ -48,10 +49,18 @@ function createChangedTasks(statusTasks: TaskListWithStatus[], dropResult: DropR return newStatusTasks; } -// ToDo: TASK_SPECIAL_DUMMY 부분을 react query로 변경할 것, mutation 작업이 같이 들어가야함 // ToDo: DnD시 가시성을 위한 애니메이션 처리 추가할 것 export default function KanbanPage() { - const [statusTasks, setStatusTasks] = useState(TASK_SPECIAL_DUMMY); + const { project } = useProjectContext(); + const { statusTaskList } = useReadStatusTasks(project.projectId); + const { mutate: updateTaskOrderMutate } = useUpdateTasksOrder(project.projectId); + const [localStatusTaskList, setLocalStatusTaskList] = useState(statusTaskList); + + useEffect(() => { + if (statusTaskList) { + setLocalStatusTaskList(statusTaskList); + } + }, [statusTaskList]); const handleDragEnd = (dropResult: DropResult) => { const { source, destination, type } = dropResult; @@ -60,14 +69,15 @@ export default function KanbanPage() { if (source.droppableId === destination.droppableId && source.index === destination.index) return; if (type === DND_TYPE.STATUS) { - const newStatusTasks = createChangedStatus(statusTasks, dropResult); - return setStatusTasks(newStatusTasks); + const newStatusList = createChangedStatus(localStatusTaskList, dropResult); + // return setStatusTasks(newStatusList); } if (type === DND_TYPE.TASK) { const isSameStatus = source.droppableId === destination.droppableId; - const newStatusTasks = createChangedTasks(statusTasks, dropResult, isSameStatus); - return setStatusTasks(newStatusTasks); + const newStatusTaskList = createChangedTasks(localStatusTaskList, dropResult, isSameStatus); + setLocalStatusTaskList(newStatusTaskList); + updateTaskOrderMutate(newStatusTaskList); } }; @@ -80,7 +90,7 @@ export default function KanbanPage() { ref={statusDropProvided.innerRef} {...statusDropProvided.droppableProps} > - {statusTasks.map((statusTask) => ( + {localStatusTaskList.map((statusTask) => ( ))} {statusDropProvided.placeholder} diff --git a/src/services/taskService.ts b/src/services/taskService.ts index d9730eed..c1711c67 100644 --- a/src/services/taskService.ts +++ b/src/services/taskService.ts @@ -2,17 +2,35 @@ import { authAxios } from '@services/axiosProvider'; import type { AxiosRequestConfig } from 'axios'; import type { Project } from '@/types/ProjectType'; -import type { TaskListWithStatus } from '@/types/TaskType'; +import type { TaskListWithStatus, TaskOrderForm } from '@/types/TaskType'; /** - * 프로젝트에 속한 모든 할일 목록 조회 API + * 프로젝트에 속한 모든 일정 목록 조회 API * * @export * @async - * @param {Project['projectId']} projectId - 대상 프로젝트 ID + * @param {Project['projectId']} projectId - 대상 프로젝트 ID * @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체 * @returns {Promise>} */ export async function findTaskList(projectId: Project['projectId'], axiosConfig: AxiosRequestConfig = {}) { return authAxios.get(`project/${projectId}/task`, axiosConfig); } + +/** + * 일정 순서 변경 API + * + * @export + * @async + * @param {Project['projectId']} projectId - 프로젝트 ID + * @param {TaskOrder} newOrderData - 새로 정렬된 일정 목록 객체 + * @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체 + * @returns {Promise>} + */ +export async function updateTaskOrder( + projectId: Project['projectId'], + newOrderData: TaskOrderForm, + axiosConfig: AxiosRequestConfig = {}, +) { + return authAxios.patch(`project/${projectId}/task/order`, newOrderData, axiosConfig); +} diff --git a/src/types/TaskType.tsx b/src/types/TaskType.tsx index a30b6ec7..3b59c33a 100644 --- a/src/types/TaskType.tsx +++ b/src/types/TaskType.tsx @@ -20,8 +20,10 @@ export type Task = { sortOrder: number; }; +export type TaskOrder = Pick; +export type TaskOrderForm = { tasks: TaskOrder[] }; + export type TaskForm = Omit; export type TaskWithStatus = RenameKeys, StatusKeyMapping> & Task; - export type TaskListWithStatus = Omit & { tasks: Task[] };