Skip to content

Commit

Permalink
Refactor: #153 유저 검색을 위한 SearchUserInput 컴포넌트 리팩토링 (#154)
Browse files Browse the repository at this point in the history
* Refactor: #153 전체 유저 검색 & 팀 유저 검색 axios 로직 수정

* Refactor: #153 SearchUserInput 컴포넌트 디바운스, Abort 로직 추가

* Refactor: #153 callback type 분리

* Rename: #153 파일 typo 수정

* Feat: #153 Exhaustive Check 기능 추가
  • Loading branch information
Seok93 authored Sep 24, 2024
1 parent e11a62e commit f804bef
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 48 deletions.
54 changes: 50 additions & 4 deletions src/components/common/SearchUserInput.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,75 @@
import { useCallback, useEffect, useRef } from 'react';
import { IoSearch } from 'react-icons/io5';
import exhaustiveCheck from '@utils/exhaustiveCheck';

import type { SearchUser } from '@/types/UserType';
import type { SearchCallback } from '@/types/SearchCallbackType';

type SearchInputProps = {
id: string;
label: string;
keyword: string;
searchId: number;
loading: boolean;
userList: SearchUser[];
searchCallbackInfo: SearchCallback;
onKeywordChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSearchKeyup: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onSearchClick: () => void;
onUserClick: (user: SearchUser) => void;
};

export default function SearchUserInput({
id,
label,
searchId,
keyword,
loading,
userList,
searchCallbackInfo,
onKeywordChange: handleKeywordChange,
onSearchKeyup: handleSearchKeyUp,
onSearchClick: handleSearchClick,
onUserClick: handleUserClick,
}: SearchInputProps) {
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);

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

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

const { type, searchCallback } = searchCallbackInfo;
switch (type) {
case 'ALL':
searchCallback(keyword, { signal });
break;
case 'TEAM':
searchCallback(searchId, keyword, { signal });
break;
case 'PROJECT':
searchCallback(searchId, keyword, { signal });
break;
default:
exhaustiveCheck(type, '사용자 검색 범위가 올바르지 않습니다.');
}
}, [searchCallbackInfo, searchId, keyword]);

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

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

return (
<label htmlFor={id} className="group mb-10 flex items-center gap-5">
<h3 className="text-large">{label}</h3>
Expand Down
48 changes: 12 additions & 36 deletions src/components/modal/task/ModalTaskForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { DateTime } from 'luxon';
import { FormProvider, useForm } from 'react-hook-form';

Expand All @@ -25,6 +25,7 @@ import type { SearchUser, UserWithRole } from '@/types/UserType';
import type { Project } from '@/types/ProjectType';
import type { Task, TaskForm } from '@/types/TaskType';
import type { CustomFile } from '@/types/FileType';
import type { ProjectSearchCallback } from '@/types/SearchCallbackType';

type ModalTaskFormProps = {
formId: string;
Expand All @@ -36,8 +37,6 @@ type ModalTaskFormProps = {
// ToDo: React Query Error시 처리 추가할 것
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 [keyword, setKeyword] = useState('');
const [assignees, setAssignees] = useState<UserWithRole[]>([]);
Expand All @@ -49,7 +48,11 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
const { data: userList = [], loading, clearData, fetchData } = useAxios(findUserByProject);
const { toastInfo, toastWarn } = useToast();

// ToDo: 상태 수정 모달 작성시 기본값 설정 방식 변경할 것
const searchCallbackInfo: ProjectSearchCallback = useMemo(
() => ({ type: 'PROJECT', searchCallback: fetchData }),
[fetchData],
);

const methods = useForm<TaskForm>({
mode: 'onChange',
defaultValues: {
Expand All @@ -62,6 +65,7 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
files: [],
},
});

const {
register,
watch,
Expand All @@ -70,42 +74,15 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
formState: { errors },
} = methods;

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 (!isStatusLoading && statusList) {
setValue('statusId', statusList[0].statusId);
}
}, [isStatusLoading, statusList]);

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

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

const handleSearchClick = () => searchUsers();

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

const handleUserClick = (user: SearchUser) => {
const handleAssigneeClick = (user: SearchUser) => {
const isIncludedUser = assignees.find((assignee) => assignee.userId === user.userId);
if (isIncludedUser) return toastInfo('이미 포함된 수행자입니다');

Expand Down Expand Up @@ -196,18 +173,17 @@ export default function ModalTaskForm({ formId, project, taskId, onSubmit }: Mod
endDateFieldName="endDate"
/>

{/* ToDo: 검색UI 공용 컴포넌트로 추출할 것 */}
<div className="mb-20">
<SearchUserInput
id="search"
label="수행자"
keyword={keyword}
searchId={projectId}
loading={loading}
userList={userList}
searchCallbackInfo={searchCallbackInfo}
onKeywordChange={handleKeywordChange}
onSearchKeyup={handleSearchKeyUp}
onSearchClick={handleSearchClick}
onUserClick={handleUserClick}
onUserClick={handleAssigneeClick}
/>
<AssigneeList assigneeList={assignees} onAssigneeDeleteClick={handleAssigneeDeleteClick} />
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/services/teamService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { authAxios } from '@services/axiosProvider';
import type { AxiosRequestConfig } from 'axios';
import type { User } from '@/types/UserType';
import type { SearchUser, User } from '@/types/UserType';
import type { Team } from '@/types/TeamType';

/**
Expand All @@ -11,14 +11,14 @@ import type { Team } from '@/types/TeamType';
* @param {Team['teamId']} teamId - 팀 아이디
* @param {User['nickname']} nickname - 유저 닉네임
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse<User[]>>}
* @returns {Promise<AxiosResponse<SearchUser[]>>}
*/
async function findUserByTeam(
export async function findUserByTeam(
teamId: Team['teamId'],
nickname: User['nickname'],
axiosConfig: AxiosRequestConfig = {},
) {
return authAxios.get<User[]>(`/team/${teamId}/user/search?nickname=${nickname}`, axiosConfig);
return authAxios.get<SearchUser[]>(`/team/${teamId}/user/search?nickname=${nickname}`, axiosConfig);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/services/userService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { authAxios } from '@services/axiosProvider';
import type { AxiosRequestConfig } from 'axios';
import type { User } from '@/types/UserType';
import type { SearchUser } from '@/types/UserType';
import type { TeamListWithApproval } from '@/types/TeamType';

/**
Expand All @@ -10,10 +10,10 @@ import type { TeamListWithApproval } from '@/types/TeamType';
* @async
* @param {User['nickname']} nickname - 유저 닉네임
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse<User[]>>}
* @returns {Promise<AxiosResponse<SearchUser[]>>}
*/
async function findUser(nickname: string, axiosConfig: AxiosRequestConfig = {}) {
return authAxios.get<User[]>(`/user/search?nickname=${nickname}`, axiosConfig);
export async function findUser(nickname: string, axiosConfig: AxiosRequestConfig = {}) {
return authAxios.get<SearchUser[]>(`/user/search?nickname=${nickname}`, axiosConfig);
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/types/SearchCallbackType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { findUser } from '@services/userService';
import { findUserByTeam } from '@services/teamService';
import { findUserByProject } from '@services/projectService';

export type FetchCallback<T> = T extends (...arg: infer P) => void ? (...arg: P) => Promise<void> : never;

export type AllSearchCallback = {
type: 'ALL';
searchCallback: FetchCallback<typeof findUser>;
};

export type TeamSearchCallback = {
type: 'TEAM';
searchCallback: FetchCallback<typeof findUserByTeam>;
};

export type ProjectSearchCallback = {
type: 'PROJECT';
searchCallback: FetchCallback<typeof findUserByProject>;
};

export type SearchCallback = AllSearchCallback | TeamSearchCallback | ProjectSearchCallback;
3 changes: 3 additions & 0 deletions src/utils/exhaustiveCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function exhaustiveCheck(param: never, errorMessage: string) {
throw new Error(errorMessage);
}

0 comments on commit f804bef

Please sign in to comment.