Skip to content

Commit

Permalink
Feat: #104 일정 순서 변경 API를 이용하여 칸반 보드 DnD로 일정의 순서를 변경하는 기능 구현 (#112)
Browse files Browse the repository at this point in the history
* Formatting: #104 React Query 커스텀훅명 변경

* Feat: #104 일정 목록에 대해 더미 처리에서 커스텀훅 처리로 변경

* Feat: #104 일정 순서 변경 API를 위한 React Query 처리 추가

* Feat: #104 일정 순서 변경 API MSW 처리 추가

* Feat: #104 일정 순서 변경 기능 구현

* Chore: #104 updateStatus 함수 JSDoc 수정
  • Loading branch information
Seok93 authored Sep 8, 2024
1 parent c7f85bc commit f34d773
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 29 deletions.
4 changes: 2 additions & 2 deletions src/components/modal/task/ModalTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,7 +45,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
const [files, setFiles] = useState<CustomFile[]>([]);

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();

Expand Down
4 changes: 2 additions & 2 deletions src/hooks/query/useStatusQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function useCreateStatus(projectId: Project['projectId']) {
onSuccess: () => {
toastSuccess('프로젝트 상태를 추가하였습니다.');
queryClient.invalidateQueries({
queryKey: ['projects', projectId, 'statuses'],
queryKey: ['projects', projectId],
});
},
});
Expand All @@ -119,7 +119,7 @@ export function useUpdateStatus(projectId: Project['projectId'], statusId: Proje
onSuccess: () => {
toastSuccess('프로젝트 상태를 수정했습니다.');
queryClient.invalidateQueries({
queryKey: ['projects', projectId, 'statuses'],
queryKey: ['projects', projectId],
});
},
});
Expand Down
47 changes: 40 additions & 7 deletions src/hooks/query/useTaskQuery.ts
Original file line number Diff line number Diff line change
@@ -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[]) {
Expand All @@ -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,
Expand All @@ -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;
}
30 changes: 30 additions & 0 deletions src/mocks/mockHash.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
[key: string | number]: T;
};

// ToDo: MSW 처리중 해쉬 테이블 처리를 사용하는 부분 대체해주기
export const USERS_HASH: Hash<User> = {};
USER_DUMMY.forEach((user) => (USERS_HASH[user.userId] = user));

export const ROLES_HASH: Hash<Role> = {};
ROLE_DUMMY.forEach((role) => (ROLES_HASH[role.roleId] = role));

export const TEAMS_HASH: Hash<Team> = {};
TEAM_DUMMY.forEach((team) => (TEAMS_HASH[team.teamId] = team));

export const PROJECTS_HASH: Hash<Project> = {};
PROJECT_DUMMY.forEach((project) => (PROJECTS_HASH[project.projectId] = project));

export const STATUSES_HASH: Hash<ProjectStatus> = {};
STATUS_DUMMY.forEach((status) => (STATUSES_HASH[status.statusId] = status));

export const TASK_HASH: Hash<Task> = {};
TASK_DUMMY.forEach((task) => (TASK_HASH[task.taskId] = task));
29 changes: 28 additions & 1 deletion src/mocks/services/taskServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
6 changes: 3 additions & 3 deletions src/pages/project/CalendarPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -38,7 +38,7 @@ export default function CalendarPage() {
const { showModal, openModal, closeModal } = useModal();
const [selectedTask, setSelectedTask] = useState<TaskWithStatus>();
const [date, setDate] = useState<Date>(() => 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]);

Expand Down Expand Up @@ -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),
Expand Down
30 changes: 20 additions & 10 deletions src/pages/project/KanbanPage.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<TaskListWithStatus[]>(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;
Expand All @@ -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);
}
};

Expand All @@ -80,7 +90,7 @@ export default function KanbanPage() {
ref={statusDropProvided.innerRef}
{...statusDropProvided.droppableProps}
>
{statusTasks.map((statusTask) => (
{localStatusTaskList.map((statusTask) => (
<ProjectStatusContainer key={statusTask.statusId} statusTask={statusTask} />
))}
{statusDropProvided.placeholder}
Expand Down
24 changes: 21 additions & 3 deletions src/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AxiosResponse<TaskListWithStatus[]>>}
*/
export async function findTaskList(projectId: Project['projectId'], axiosConfig: AxiosRequestConfig = {}) {
return authAxios.get<TaskListWithStatus[]>(`project/${projectId}/task`, axiosConfig);
}

/**
* 일정 순서 변경 API
*
* @export
* @async
* @param {Project['projectId']} projectId - 프로젝트 ID
* @param {TaskOrder} newOrderData - 새로 정렬된 일정 목록 객체
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse<void>>}
*/
export async function updateTaskOrder(
projectId: Project['projectId'],
newOrderData: TaskOrderForm,
axiosConfig: AxiosRequestConfig = {},
) {
return authAxios.patch(`project/${projectId}/task/order`, newOrderData, axiosConfig);
}
4 changes: 3 additions & 1 deletion src/types/TaskType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export type Task = {
sortOrder: number;
};

export type TaskOrder = Pick<Task, 'statusId' | 'taskId' | 'sortOrder'>;
export type TaskOrderForm = { tasks: TaskOrder[] };

export type TaskForm = Omit<Task, 'taskId' | 'files'>;

export type TaskWithStatus = RenameKeys<Omit<ProjectStatus, 'projectId'>, StatusKeyMapping> & Task;

export type TaskListWithStatus = Omit<ProjectStatus, 'projectId'> & { tasks: Task[] };

0 comments on commit f34d773

Please sign in to comment.