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: #115 일정 등록 모달의 파일 업로드 기능 구현 #166

Merged
merged 18 commits into from
Sep 28, 2024
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a79892c
Feat: #115 파일 업로드 API를 위한 React Query 처리 추가
Seok93 Sep 27, 2024
1af548b
Feat: #115 일정 파일 업로드 API를 위한 MSW 처리 추가
Seok93 Sep 27, 2024
3013c08
Feat: #115 파일 Validation 추가
Seok93 Sep 27, 2024
67236be
Feat: #115 파일 업로드 기능 구현 (병렬 처리)
Seok93 Sep 27, 2024
f3f68f0
Chore: #115 허용하는 파일 확장자 constants로 정리
Seok93 Sep 27, 2024
a7b30d8
Feat: #115 FileDropZone accept props 추가
Seok93 Sep 27, 2024
b2cb824
Feat: #115 Task Form 관련 타입 수정 및 추가
Seok93 Sep 27, 2024
aba0a39
Feat: #115 일정 파일 업로드 Validation 추가
Seok93 Sep 27, 2024
3e7e8bb
Chore: #115 더미 파일 설정 추가
Seok93 Sep 27, 2024
201e4f9
Chore: #115 CreateModalTask 컴포넌트 import 경로 수정
Seok93 Sep 27, 2024
6db9bb6
Chore: 파일 validation warn message 수정
Seok93 Sep 27, 2024
f0e5020
Fix: #115 파일 최대 개수 Validation 수정
Seok93 Sep 27, 2024
5a4d769
Fix: #115 파일 최대 개수 Validation 수정
Seok93 Sep 27, 2024
613775b
Feat: #115 병렬 처리된 파일의 성공과 실패 목록 메세지 노출
Seok93 Sep 27, 2024
ae1299d
Feat: #115 파일 업로드 성공은 묶어서, 실패는 개별 메세지 노출로 변경
Seok93 Sep 27, 2024
ed0ca33
Feat: #115 FileDropZone 컴포넌트 props 변경에 따른 코드 수정
Seok93 Sep 27, 2024
1744999
Feat: #115 파일 Validation 예외 처리 추가
Seok93 Sep 27, 2024
c96ec9e
Chore: #115 일정 파일 accept 설정 상수로 추출
Seok93 Sep 27, 2024
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
13 changes: 11 additions & 2 deletions src/components/common/FileDropZone.tsx
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ type FileDropZoneProps = {
id: string;
label: string;
files: FileInfo[];
accept: string;
onFileDrop: (e: React.DragEvent<HTMLElement>) => void;
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFileDeleteClick: (fileId: string) => void;
@@ -14,11 +15,11 @@ type FileDropZoneProps = {
const DEFAULT_BG_COLOR = 'inherit';
const FILE_DRAG_OVER_BG_COLOR = '#e1f4d9';

// ToDo: 파일 업로드 API 작업시 구조 다시 한 번 확인해보기
export default function FileDropZone({
id,
label,
files,
accept = '*',
onFileDrop,
onFileChange: handleFileChange,
onFileDeleteClick: handleFileDeleteClick,
@@ -41,7 +42,15 @@ export default function FileDropZone({
return (
<label htmlFor={id}>
<h3 className="text-large">{label}</h3>
<input type="file" id={id} className="h-0 w-0 opacity-0" multiple hidden onChange={handleFileChange} />
<input
id={id}
type="file"
accept={accept}
className="h-0 w-0 opacity-0"
onChange={handleFileChange}
multiple
hidden
/>
<div
role="button"
tabIndex={0}
47 changes: 41 additions & 6 deletions src/components/modal/task/CreateModalTask.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { useQueryClient } from '@tanstack/react-query';
import ModalLayout from '@layouts/ModalLayout';
import ModalPortal from '@components/modal/ModalPortal';
import ModalTaskForm from '@components/modal/task/ModalTaskForm';
import ModalFormButton from '@components/modal/ModalFormButton';
import { useCreateStatusTask, useReadStatusTasks } from '@hooks/query/useTaskQuery';
import useToast from '@hooks/useToast';
import { useCreateStatusTask, useReadStatusTasks, useUploadTaskFile } from '@hooks/query/useTaskQuery';

import type { SubmitHandler } from 'react-hook-form';
import type { TaskForm } from '@/types/TaskType';
import type { Task, TaskForm } from '@/types/TaskType';
import type { Project } from '@/types/ProjectType';
import type { ProjectStatus } from '@/types/ProjectStatusType';
import type { FileUploadFailureResult, FileUploadSuccessResult } from '@/types/FileType';

type CreateModalTaskProps = {
project: Project;
onClose: () => void;
};

export default function CreateModalTask({ project, onClose: handleClose }: CreateModalTaskProps) {
const { toastError } = useToast();
const { mutate: createTaskMutate } = useCreateStatusTask(project.projectId);
const { toastSuccess, toastError } = useToast();
const { mutateAsync: createTaskInfoMutateAsync } = useCreateStatusTask(project.projectId);
const { mutateAsync: createTaskFileMutateAsync } = useUploadTaskFile(project.projectId);
const { statusTaskList } = useReadStatusTasks(project.projectId);
const queryClient = useQueryClient();

const getLastSortOrder = (statusId: ProjectStatus['statusId']) => {
const statusTask = statusTaskList.find((statusTask) => statusTask.statusId === Number(statusId));
@@ -29,10 +33,41 @@ export default function CreateModalTask({ project, onClose: handleClose }: Creat
return statusTask.tasks.length + 1;
};

// ToDo: 파일 생성 위한 네트워크 로직 추가
const taskFilesUpload = async (taskId: Task['taskId'], files: File[]) => {
const createFilePromises = files.map((file) =>
createTaskFileMutateAsync({ taskId, file }).then(
(): FileUploadSuccessResult => ({ status: 'fulfilled', file }),
(error): FileUploadFailureResult => ({ status: 'rejected', file, error }),
),
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
);

const results = (await Promise.allSettled(createFilePromises)) as PromiseFulfilledResult<
FileUploadSuccessResult | FileUploadFailureResult
>[];
const queryKey = ['projects', project.projectId, 'tasks'];
queryClient.invalidateQueries({ queryKey });

const fulfilledFileList: FileUploadSuccessResult[] = [];
const rejectedFileList: FileUploadFailureResult[] = [];
results
.map((result) => result.value)
.forEach((result) => {
if (result.status === 'fulfilled') fulfilledFileList.push(result);
else rejectedFileList.push(result);
});

if (fulfilledFileList.length > 0) toastSuccess(`${fulfilledFileList.length}개의 파일 업로드에 성공했습니다.`);
};
Seok93 marked this conversation as resolved.
Show resolved Hide resolved

const handleSubmit: SubmitHandler<TaskForm> = async (taskFormData) => {
const { files, ...taskInfoForm } = taskFormData;
const sortOrder = getLastSortOrder(taskFormData.statusId);
createTaskMutate({ ...taskFormData, sortOrder });

// 일정 정보 등록
const { data: taskInfo } = await createTaskInfoMutateAsync({ ...taskInfoForm, sortOrder });

Seok93 marked this conversation as resolved.
Show resolved Hide resolved
// 일정 파일 업로드
if (files.length > 0) await taskFilesUpload(taskInfo.taskId, files);
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
handleClose();
};
return (
44 changes: 35 additions & 9 deletions src/components/modal/task/ModalTaskForm.tsx
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import useAxios from '@hooks/useAxios';
import { useReadStatuses } from '@hooks/query/useStatusQuery';
import { useReadStatusTasks } from '@hooks/query/useTaskQuery';
import { useReadProjectUserRoleList } from '@hooks/query/useProjectQuery';
import Validator from '@utils/Validator';
import { convertBytesToString } from '@utils/converter';
import { findUserByProject } from '@services/projectService';

@@ -106,22 +107,46 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
setValue('assignees', assigneesIdList);
};

const updateFiles = (newFiles: FileList) => {
// ToDo: 일정 수정에서도 사용하도록 분리할 것, 어디에 분리하는게 좋으려나?
const isValidTaskFile = (file: File) => {
if (!Validator.isValidFileName(file.name)) {
toastWarn(
`${file.name} 파일은 업로드 할 수 없습니다. 파일명은 한글, 영어, 숫자, 특수기호(.-_), 공백문자만 가능합니다.`,
);
return false;
}

if (!Validator.isValidFileExtension(TASK_SETTINGS.FILE_TYPES, file.type)) {
toastWarn(`${file.name} 파일은 업로드 할 수 없습니다. 지원하지 않는 파일 타입입니다.`);
return false;
}
Seok93 marked this conversation as resolved.
Show resolved Hide resolved

if (!Validator.isValidFileSize(TASK_SETTINGS.MAX_FILE_SIZE, file.size)) {
toastWarn(
`${file.name} 파일은 업로드 할 수 없습니다. ${convertBytesToString(TASK_SETTINGS.MAX_FILE_SIZE)} 이하의 파일만 업로드 가능합니다.`,
);
return false;
}

return true;
};
Seok93 marked this conversation as resolved.
Show resolved Hide resolved

const updateTaskFiles = (newFiles: FileList) => {
// 최대 파일 등록 개수 확인
if (files.length + newFiles.length > TASK_SETTINGS.MAX_FILE_COUNT) {
if (!Validator.isValidFileCount(TASK_SETTINGS.MAX_FILE_COUNT, files.length + newFiles.length)) {
return toastWarn(`최대로 등록 가능한 파일수는 ${TASK_SETTINGS.MAX_FILE_COUNT}개입니다.`);
}

// 새로운 파일별 파일 크기 확인 & 고유 ID 부여
// 새로운 파일 Validation & 고유 ID 부여
const originFiles: File[] = files.map(({ file }) => file);
const customFiles: CustomFile[] = [];
for (let i = 0; i < newFiles.length; i++) {
const file = newFiles[i];
if (file.size > TASK_SETTINGS.MAX_FILE_SIZE) {
return toastWarn(`최대 ${convertBytesToString(TASK_SETTINGS.MAX_FILE_SIZE)} 이하의 파일만 업로드 가능합니다.`);

if (isValidTaskFile(file)) {
originFiles.push(file);
customFiles.push({ fileId: `${file.name}_${Date.now()}`, fileName: file.name, file });
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
}
originFiles.push(file);
customFiles.push({ fileId: `${file.name}_${Date.now()}`, fileName: file.name, file });
}
setValue('files', originFiles);
setFiles((prev) => [...prev, ...customFiles]);
@@ -130,13 +155,13 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { files } = e.target;
if (!files || files.length === 0) return;
updateFiles(files);
updateTaskFiles(files);
};

const handleFileDrop = (e: React.DragEvent<HTMLElement>) => {
const { files } = e.dataTransfer;
if (!files || files.length === 0) return;
updateFiles(files);
updateTaskFiles(files);
};

const handleFileDeleteClick = (fileId: string) => {
@@ -194,6 +219,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
id="files"
label="첨부파일"
files={files}
accept={TASK_SETTINGS.FILE_ACCEPT}
onFileChange={handleFileChange}
onFileDrop={handleFileDrop}
onFileDeleteClick={handleFileDeleteClick}
1 change: 1 addition & 0 deletions src/components/modal/task/UpdateModalTask.tsx
Original file line number Diff line number Diff line change
@@ -193,6 +193,7 @@ export default function UpdateModalTask({ project, taskId, onClose: handleClose
id="files"
label="첨부파일"
files={taskFileList}
accept={TASK_SETTINGS.FILE_ACCEPT}
onFileChange={handleFileChange}
onFileDrop={handleFileDrop}
onFileDeleteClick={handleFileDeleteClick}
44 changes: 44 additions & 0 deletions src/constants/mimeFileType.ts
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 이미지 파일
export const JPG = 'image/jpeg';
export const PNG = 'image/png';
export const SVG = 'image/svg+xml';
export const WEBP = 'image/webp';

// 압축 파일
export const ZIP = 'application/zip';
export const RAR = 'application/x-rar-compressed';
export const Z7 = 'application/x-7z-compressed';
export const ALZ = 'application/x-alz';
export const EGG = 'application/x-egg';

// 문서 파일
export const TXT = 'text/plain';
export const PDF = 'application/pdf';
export const HWP = 'application/x-hwp';
export const DOC = 'application/msword';
export const XLS = 'application/vnd.ms-excel';
export const PPT = 'application/vnd.ms-powerpoint';
export const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
export const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';

export const TASK_ACCEPT_FILE_TYPES = [
JPG,
PNG,
SVG,
WEBP,
ZIP,
RAR,
Z7,
ALZ,
EGG,
PDF,
TXT,
HWP,
DOC,
XLS,
PPT,
DOCX,
XLSX,
PPTX,
];
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions src/constants/regex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { USER_SETTINGS } from '@constants/settings';

export const FILE_NAME_REGEX = /^[가-힣a-zA-Z0-9 _\-.]+$/;
export const EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9-]+\.[a-z]{2,3}(?:\.[a-z]{2,3})?$/i;
export const PHONE_REGEX = /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/;
export const PASSWORD_REGEX = new RegExp(
4 changes: 4 additions & 0 deletions src/constants/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DAY, MB, MINUTE, SECOND } from '@constants/units';
import { TASK_ACCEPT_FILE_TYPES } from '@constants/mimeFileType';
Seok93 marked this conversation as resolved.
Show resolved Hide resolved

export const AUTH_SETTINGS = Object.freeze({
// ACCESS_TOKEN_EXPIRATION: 5 * SECOND, // 테스트용 5초
@@ -18,7 +19,10 @@ export const USER_SETTINGS = Object.freeze({
MAX_EMAIL_LENGTH: 128,
});

// prettier-ignore
export const TASK_SETTINGS = Object.freeze({
MAX_FILE_SIZE: 2 * MB,
MAX_FILE_COUNT: 10,
FILE_ACCEPT: '.jpg, .jpeg, .png, .svg, .webp, .pdf, .txt, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .hwp, .zip, .rar, .7z, .alz, .egg',
FILE_TYPES: TASK_ACCEPT_FILE_TYPES,
});
22 changes: 20 additions & 2 deletions src/hooks/query/useTaskQuery.ts
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import {
findTaskList,
updateTaskInfo,
updateTaskOrder,
uploadTaskFile,
} from '@services/taskService';
import useToast from '@hooks/useToast';

@@ -17,10 +18,10 @@ import type { Project } from '@/types/ProjectType';
import type {
Task,
TaskCreationForm,
TaskInfoForm,
TaskListWithStatus,
TaskOrder,
TaskUpdateForm,
TaskUploadForm,
} from '@/types/TaskType';

function getTaskNameList(taskList: TaskListWithStatus[], excludedTaskName?: Task['name']) {
@@ -35,12 +36,15 @@ function getTaskNameList(taskList: TaskListWithStatus[], excludedTaskName?: Task
// Todo: Task Query D로직 작성하기
// 일정 생성
export function useCreateStatusTask(projectId: Project['projectId']) {
const { toastSuccess } = useToast();
const { toastError, toastSuccess } = useToast();
const queryClient = useQueryClient();
const queryKey = ['projects', projectId, 'tasks'];

const mutation = useMutation({
mutationFn: (formData: TaskCreationForm) => createTask(projectId, formData),
onError: () => {
toastError('프로젝트 일정 등록 중 오류가 발생했습니다. 잠시후 다시 등록해주세요.');
},
onSuccess: () => {
toastSuccess('프로젝트 일정을 등록하였습니다.');
queryClient.invalidateQueries({ queryKey });
@@ -50,6 +54,20 @@ export function useCreateStatusTask(projectId: Project['projectId']) {
return mutation;
}

// 일정 단일 파일 업로드
export function useUploadTaskFile(projectId: Project['projectId']) {
const { toastError } = useToast();
const mutation = useMutation({
mutationFn: ({ taskId, file }: TaskUploadForm) =>
uploadTaskFile(projectId, taskId, file, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
onError: (error, { file }) => toastError(`${file.name} 파일 업로드에 실패 했습니다.`),
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
});

return mutation;
}
Seok93 marked this conversation as resolved.
Show resolved Hide resolved

// 상태별 일정 목록 조회
export function useReadStatusTasks(projectId: Project['projectId'], taskId?: Task['taskId']) {
const {
31 changes: 28 additions & 3 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
@@ -32,6 +32,12 @@ type TaskFile = {
fileUrl: string;
};

type FileInfo = {
fileId: number;
taskId: number;
file: Blob;
};

export const JWT_TOKEN_DUMMY = 'mocked-header.mocked-payload-4.mocked-signature';

export const emailVerificationCode = '1234';
@@ -689,19 +695,38 @@ export const TASK_FILE_DUMMY: TaskFile[] = [
{
fileId: 1,
taskId: 1,
fileName: '최종본.pdf',
fileName: '최종본.txt',
fileUrl: '',
},
{
fileId: 2,
taskId: 1,
fileName: '참고자료.pdf',
fileName: '참고자료.txt',
fileUrl: '',
},
{
fileId: 3,
taskId: 2,
fileName: '명세서.pdf',
fileName: '명세서.txt',
fileUrl: '',
},
];

// MSW 파일 임시 저장을 위한 변수
export const FILE_DUMMY: FileInfo[] = [
{
fileId: 1,
taskId: 1,
file: new Blob(['최종본 내용'], { type: 'text/plain' }),
},
{
fileId: 2,
taskId: 1,
file: new Blob(['참고자료 내용'], { type: 'text/plain' }),
},
{
fileId: 3,
taskId: 2,
file: new Blob(['명세서 내용'], { type: 'text/plain' }),
},
];
1 change: 1 addition & 0 deletions src/mocks/mockHash.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import type { Project } from '@/types/ProjectType';
import type { ProjectStatus } from '@/types/ProjectStatusType';
import type { Task } from '@/types/TaskType';

// ToDo: undefined가 나올 수도 있음, 나중에 MSW CRUD 관련 로직들 함수로 모두 정리하기.
Seok93 marked this conversation as resolved.
Show resolved Hide resolved
type Hash<T> = {
[key: string | number]: T;
};
Loading