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: #63 할일 추가 모달 수행자 검색 기능 구현 #75

Merged
merged 14 commits into from
Aug 19, 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
121 changes: 102 additions & 19 deletions src/components/modal/task/ModalTaskForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DateTime } from 'luxon';
import { IoSearch } from 'react-icons/io5';
import { useForm } from 'react-hook-form';
import { IoSearch } from 'react-icons/io5';

import { TASK_VALIDATION_RULES } from '@constants/formValidationRules';
import ToggleButton from '@components/common/ToggleButton';
import DuplicationCheckInput from '@components/common/DuplicationCheckInput';
import useAxios from '@hooks/useAxios';
import useTaskQuery from '@hooks/query/useTaskQuery';
import useStatusQuery from '@hooks/query/useStatusQuery';
import { findUserByProject } from '@services/projectService';

import type { SubmitHandler } from 'react-hook-form';
import type { User } from '@/types/UserType';
import type { Project } from '@/types/ProjectType';
import type { Task, TaskForm } from '@/types/TaskType';

Expand All @@ -20,9 +24,17 @@ type ModalTaskFormProps = {
};

export default function ModalTaskForm({ formId, project, taskId, onSubmit }: ModalTaskFormProps) {
const { projectId, startDate, endDate } = project;
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [hasDeadline, setHasDeadline] = useState(false);
const { statusList } = useStatusQuery(project.projectId, taskId);
const { taskNameList } = useTaskQuery(project.projectId);
const [keyword, setKeyword] = useState('');
const [workers, setWorkers] = useState<User[]>([]);

const { statusList } = useStatusQuery(projectId, taskId);
const { taskNameList } = useTaskQuery(projectId);
const { data, loading, clearData, fetchData } = useAxios(findUserByProject);

// ToDo: 상태 수정 모달 작성시 기본값 설정 방식 변경할 것
const {
register,
Expand All @@ -43,12 +55,49 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
},
});

const searchUsers = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortControllerRef.current) abortControllerRef.current.abort();

abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;

fetchData(projectId, keyword, { signal });
}, [fetchData, projectId, keyword]);

useEffect(() => {
if (keyword.trim()) {
debounceRef.current = setTimeout(() => searchUsers(), 500);
}
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortControllerRef.current) abortControllerRef.current.abort();
};
}, [searchUsers, keyword]);

const handleDeadlineToggle = () => {
setValue('endDate', getValues('startDate'));
clearErrors('endDate');
setHasDeadline((prev) => !prev);
};

const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => setKeyword(e.target.value);

const handleSearchClick = () => searchUsers();

const handleSearchKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key.toLocaleLowerCase() === 'enter') {
e.preventDefault();
searchUsers();
}
};

const handleUserClick = (user: User) => {
setWorkers((prev) => [...prev, user]);
setKeyword('');
clearData();
};

return (
<form
id={formId}
Expand Down Expand Up @@ -99,7 +148,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
<input
type="date"
id="startDate"
{...register('startDate', TASK_VALIDATION_RULES.START_DATE(project.startDate, project.endDate))}
{...register('startDate', TASK_VALIDATION_RULES.START_DATE(startDate, endDate))}
/>
<div className={`my-5 h-10 grow text-xs text-error ${errors.startDate ? 'visible' : 'invisible'}`}>
{errors.startDate?.message}
Expand All @@ -117,7 +166,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
disabled={!hasDeadline}
{...register(
'endDate',
TASK_VALIDATION_RULES.END_DATE(hasDeadline, project.startDate, project.endDate, watch('startDate')),
TASK_VALIDATION_RULES.END_DATE(hasDeadline, startDate, endDate, watch('startDate')),
)}
/>
<div className={`my-5 h-10 grow text-xs text-error ${errors.endDate ? 'visible' : 'invisible'}`}>
Expand All @@ -126,19 +175,53 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
</label>
</div>

<label htmlFor="user" className="mb-20 flex items-center gap-5">
<h3 className="text-large">수행자</h3>
<div className="relative grow">
<input
type="text"
id="user"
className="h-25 w-full rounded-md border border-input pl-10 pr-25 text-regular placeholder:text-xs"
/>
<div className="absolute right-5 top-1/2 -translate-y-1/2">
<IoSearch className="size-15 text-emphasis" />
</div>
</div>
</label>
{/* ToDo: 검색UI 공용 컴포넌트로 추출할 것 */}
<div className="mb-20">
<label htmlFor="search" className="group mb-10 flex items-center gap-5">
<h3 className="text-large">수행자</h3>
<section className="relative grow">
<input
type="text"
id="search"
className="h-25 w-full rounded-md border border-input pl-10 pr-25 text-regular placeholder:text-xs"
value={keyword}
onChange={handleKeywordChange}
onKeyDown={handleSearchKeyUp}
placeholder="닉네임으로 검색해주세요."
/>
<button
type="button"
aria-label="search"
className="absolute right-5 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={handleSearchClick}
>
<IoSearch className="size-15 text-emphasis hover:text-black" />
</button>
{keyword && !loading && (
<ul className="invisible absolute left-0 right-0 rounded-md border-2 bg-white group-focus-within:visible">
{data && data.length === 0 ? (
<div className="h-20 border px-10 leading-8">&apos;{keyword}&apos; 의 검색 결과가 없습니다.</div>
) : (
data?.map((user) => (
<li className="h-20 border" key={user.userId}>
<button
type="button"
className="h-full w-full px-10 text-left hover:bg-sub"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur();
handleUserClick(user);
}}
>
{user.nickname}
</button>
</li>
))
)}
</ul>
)}
</section>
</label>
</div>

<label htmlFor="content" className="mb-20">
<h3 className="text-large">내용</h3>
Expand Down
9 changes: 9 additions & 0 deletions src/constants/units.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
// ToDo: MEMORY_UNITS 으로 묶는거 고려해보기
export const KB = 1024;
export const MB = 1024 * KB;
export const GB = 1024 * MB;

// ToDo: DeepFreeze로 변경할 것, 상수 정의 컨벤션 적용할 것
export const fileSizeUnits = Object.freeze([
{ unit: 'GB', value: GB },
{ unit: 'MB', value: MB },
{ unit: 'KB', value: KB },
{ unit: 'B', value: 1 },
]);

// ToDo: TIME_UNITS 으로 묶는거 고려해보기
export const MILLISECOND = 1;
export const SECOND = 1000 * MILLISECOND;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
Empty file removed src/hooks/.gitkeep
Empty file.
61 changes: 61 additions & 0 deletions src/hooks/useAxios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import axios from 'axios';
import { useState, useCallback } from 'react';
import type { AxiosResponse } from 'axios';

type PromiseCallback<T, P extends unknown[]> = (...args: P) => Promise<AxiosResponse<T>>;

/**
* Axios API 함수를 처리하는 커스텀 훅
*
* @export
* @template T - AxiosResponse의 응답 데이터 타입
* @template {unknown[]} P - API 함수에 전달되는 매개변수의 가변인자 타입 배열
* @param {PromiseCallback<T, P>} fetchCallback - API 요청을 수행하는 함수
* @returns {{
* data: T | undefined; // API 요청의 응답 데이터
* error: Error | null; // API 요청 중 발생한 에러
* loading: boolean; // 데이터 로딩 중인지 여부
* fetchData: (...args: P) => Promise<void>; // API 요청을 호출하는 함수
* }}
* @example
* const { data, error, loading, fetchData } = useAxios(fetchCallback) // fetchCallback에서 타입을 반환한다면, 자동 타입 추론이 가능
* const { data, error, loading, fetchData } = useAxios<User[], Parameters<typeof fetchCallback>>(fetchCallback);
*/
export default function useAxios<T, P extends unknown[]>(fetchCallback: PromiseCallback<T, P>) {
const [data, setData] = useState<T>();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);

const clearData = useCallback(() => {
setData(undefined);
setError(null);
setLoading(false);
}, []);

const fetchData = useCallback(
async (...params: P) => {
try {
setLoading(true);
const response = await fetchCallback(...params);
setData(response.data);
} catch (error: unknown) {
setError(error as Error);

if (!axios.isAxiosError(error)) return;

if (error.request) {
// ToDo: 네트워크 요청을 보냈지만 응답이 없는 경우 에러 처리
} else if (error.response) {
// ToDo: 요청후 응답을 받았지만 200 이외의 응답 코드인 경우 예외 처리
} else {
// ToDo: request 설정 오류
}
} finally {
setLoading(false);
}
},
[fetchCallback],
);

return { data, error, loading, clearData, fetchData };
}
2 changes: 1 addition & 1 deletion src/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
import handlers from '@mocks/handlers';

export const worker = setupWorker(...handlers);
9 changes: 7 additions & 2 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
// ToDo: API 설계되면 엔드포인트 넣어서 설정할 것.
export const handlers = [];
import userServiceHandler from '@mocks/services/userServiceHandler';
import teamServiceHandler from '@mocks/services/teamServiceHandler';
import projectServiceHandler from '@mocks/services/projectServiceHandler';

const handlers = [...userServiceHandler, ...teamServiceHandler, ...projectServiceHandler];

export default handlers;
Loading