Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: #104 일정 순서 변경 API를 이용하여 칸반 보드 DnD로 일정의 순서를 변경하는 기능 구현 #112

Merged
merged 6 commits into from
Sep 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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[] };