Name | ํฉ์ํ | ์ด์คํธ | ๋ฐ์ํ | ์ด์๋ฏผ | ์ ๋ํ |
---|---|---|---|---|---|
Profile | |||||
GitHub | @rjsej12 | @wujuno | @pySoo | @sangminlee98 | @robin14dev |
Name | ๊ฐ๋ช ์ฃผ | ๋ฐ๊ฒธ์ | ์ ์ ์ | ๊ณ ์์ฑ | ์ถํ์ฌ |
---|---|---|---|---|---|
Profile | |||||
GitHub | @myungju030 | @seoltang | @wjdwjdtn92 | @free-ko | @Chuhj |
๊ณผ์ ์ฝ๋ ๋ฆฌํฉํ ๋ง + ์ถ์ฒ ๊ฒ์์ฐฝ ๊ตฌํ + ๋ฌดํ ์คํฌ๋กค
์งํ ๊ธฐ๊ฐ: 2023-05-14 ~ 2023-05-17
- ์ํฐ๋ ํ๋ฆฌ์จ๋ณด๋ฉ ํ๋ก ํธ์๋ ์ธํด์ญ 4์ฃผ์ฐจ ๊ณผ์
- ๋ชฉ์ฐจ
- ๋ฐฐํฌ ๋งํฌ
- ๋์ ํ๋ฉด
- ์ฌ์ฉํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
- ํ๋ก์ ํธ ๊ตฌ์กฐ
- ํ๋ก์ ํธ ์คํ ๋ฐฉ๋ฒ
- ๊ณผ์ ์ํ ๋ด์ฉ
https://pre-onboarding-10th-4-3.netlify.app/
๐ฆsrc
โโโ ๐__mocks__
โโโ ๐__tests__
โโโ ๐api
โโโ ๐components
โ โโโ ๐Dropdown
| โโโ ๐DropdownItem
| โโโ ๐Header
| โโโ ๐InputTodo
| โโโ ๐TodoItem
| โโโ ๐TodoList
| โโโ ๐shared
โ โโโ ๐LoadingSpinner
โ โโโ ๐PlusButton
โ โโโ ๐TrashButton
โโโ ๐constants
โโโ ๐hooks
โโโ ๐pages
โโโ ๐types
โโโ ๐utils
๋ ํ์งํ ๋ฆฌ ํด๋ก
$ git clone https://github.com/WANTED-TEAM03/pre-onboarding-10th-4-3.git
ํจํค์ง ์ค์น
$ yarn
ํ๊ฒฝ ๋ณ์ ์ ๋ ฅ
// .env
REACT_APP_API_URL=
REACT_APP_TOKEN=
์ ํ๋ฆฌ์ผ์ด์ ์คํ
$ yarn start
ํ ์คํธ ์คํ
$ yarn test
-
๊ธฐ์กด ์ฝ๋ ๋ฆฌํฉํ ๋ง
- ๊ธฐ์กด ์๋ฐ์คํฌ๋ฆฝํธ ํ์ผ์
ํ์ ์คํฌ๋ฆฝํธ
๋ก ๋ง์ด๊ทธ๋ ์ด์ ํ์ต๋๋ค. - ๊ธฐ์กด ์ฝ๋๋ค์ ํ์ ์ ์ ์ํ๊ณ ๋ณ์๋ค์ ์์ํํ์ฌ constants ํด๋์์ ๊ด๋ฆฌํ์ต๋๋ค.
CSS ๋ชจ๋ํ
๋ฅผ ์ ์ฉํ์ฌ ์ฝ๋์ ์ฌ์ฌ์ฉ์ฑ์ ๋์ด๊ณ side effect ๋ฐ์ ํ๋ฅ ์ ์ค์์ต๋๋ค.- ๊ณตํต์ ์ผ๋ก ์ฌ์ฉํ๋ Spinner์ Button์ ๋ถ๋ฆฌํ์ฌ
์ฌ์ฌ์ฉ์ฑ
์ ๋์์ต๋๋ค.
- ๊ธฐ์กด ์๋ฐ์คํฌ๋ฆฝํธ ํ์ผ์
-
์ถ์ฒ ๊ฒ์์ฐฝ ๊ตฌํ
debounce
์ ํตํด API์ ํธ์ถ์ ์ค์์ต๋๋ค.useSearch
hook์ ์ด์ฉํ์ฌ ๋น์ฆ๋์ค ๋ก์ง์ UI ์ปดํฌ๋ํธ์ ๋ถ๋ฆฌํ๊ณ ๋ฐ์ดํฐ ํ์นญ ๊ธฐ๋ฅ์ ๊ตฌํํ์ต๋๋ค.
-
๊ฒ์์ด ๋๋กญ๋ค์ด ๊ตฌํ
- ๋จ์ด๊ฐ
ํค์๋
์ ๋์ผํ ๊ฒฝ์ฐ ์์์ผ๋กํ์ฑํ
๊ธฐ๋ฅ์ ๊ตฌํํ์ต๋๋ค. - ๋๋กญ๋ค์ด ์์ดํ
ํด๋ฆญ ์
Todo ๋ฆฌ์คํธ์ ์ถ๊ฐ
๋๋๋ก ๊ตฌํํ์ต๋๋ค. useToggleModal
hook์ ์ด์ฉํ์ฌ ๋๋กญ๋ค์ด ํ ๊ธ์ ๊ตฌํํ์ต๋๋ค.
- ๋จ์ด๊ฐ
-
๋ฌดํ ์คํฌ๋กค
- Web API์
IntersectionObserver
๋ฅผ ์ฌ์ฉํ์ฌ ๋ง์ง๋ง ์์ดํ ์ด ๊ฐ์ง๋๋ฉด API๋ฅผ ํธ์ถํ๋ ๋ฌดํ ์คํฌ๋กค์ ๊ตฌํํ์ต๋๋ค. - ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ์ต๋ 10๊ฐ์ฉ ๋ฐ์์ฌ ์ ์๋๋ก ๊ตฌํํ์ต๋๋ค.
- Web API์
-
jest๋ฅผ ์ด์ฉํ ๋จ์ ํ ์คํธ
<InputTodo>
,<Dropdown>
์ปดํฌ๋ํธ์ ๋จ์ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ์ฌ ์ฃผ์ ๊ธฐ๋ฅ ๋ฐ ์ด๋ฒคํธ ๋ฐ์์ ๋ฐ๋ฅธ ๋ก์ง์ ํ ์คํธํ๊ณ ์ ํ์ต๋๋ค.
- ์ด๋ฒคํธ๋ฅผ ๊ทธ๋ฃนํํ์ฌ ํน์ ์๊ฐ์ด ์ง๋ ํ ํ๋์ ์ด๋ฒคํธ๋ง ๋ฐ์ํ๋๋ก ํ๋ Debounce ๊ธฐ์ ์ ์ฌ์ฉํ์ต๋๋ค.
-
์ผ์ ์๊ฐ ๋์ ๊ฒ์์ด๋ฅผ ๋ชจ์
debouncedInputText
๋ฅผ search API๋ฅผ ํธ์ถํ๋useSearch
hook์ ์ ๋ฌํฉ๋๋ค.// src/components/InputTodo/index.tsx const [inputText, setInputText] = useState(''); ... const debouncedInputText = useDebounce(inputText); const { recommendList } = useSearch(debouncedInputText);
-
delay
์๊ฐ์ 500ms๋ก ์ธํ ํ๊ณ , ๋ง์ง๋ง ์์ฒญ ์ดํ API๋ฅผ ํ ๋ฒ๋ง ํธ์ถํ๋๋กdebounce
๋ฅผ ์ ์ฉํ์ฌ ๋คํธ์ํฌ ๋น์ฉ์ ์ค์ด๋๋ก ๊ตฌํํ์ต๋๋ค.// src/hooks/useSearch.ts useEffect(() => { const fetchAutocompleteWords = async () => { try { const response = await getSearchedList(); } ... }; fetchAutocompleteWords(); }, [inputText]);
-
๋๋ฐ์ด์ค ์์ ์ ์ ์ง ๋ณด์๊ฐ ์ฉ์ดํ๋๋ก ์ปค์คํ ํ ์ผ๋ก ๋ถ๋ฆฌํ์ต๋๋ค.
-
์ถํ ์ฌ์ฌ์ฉ์ ๊ณ ๋ คํด value์ ํ์ ์ generic
<T>
๋ก ์ ์ธํ์ต๋๋ค.// src/hooks/useDebounce.ts export default function useDebounce<T>(value: T, delay = 500) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timerId = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(timerId); }; }, [value, delay]); return debouncedValue; }
- ์ถ์ฒ ๊ฒ์ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ธฐ ์ํด ๋ค์ํ ์ํ๋ฅผ ์ฌ์ฉํ๊ณ ์์ด์ ๋ชจ๋ํ์ ํ์์ฑ์ ๋๊ผ์ต๋๋ค.
๊ฒ์๊ณผ ๊ด๋ จ๋ ์ํ
๋ค๊ณผ ๊ฒ์์ด ๋ณ๊ฒฝ ์์ ์คํฌ๋กค์ ๋ณ๊ฒฝํ๊ณ ๋ฐ์ดํฐ๋ฅผ ์ด๊ธฐํ ํ๋์ด๊ธฐํ ํจ์
๋ค์ ๋ฐํํฉ๋๋ค.- debounced๋
inputText
๊ฐ ๋ค์ด์์ ๋, ๋น ๊ฐ์ด ์๋๋ผ๋ฉด API๋ฅผ ํธ์ถํฉ๋๋ค.
// src/hooks/useSearch.ts
const useSearch = (inputText: string) => {
...
const scrollToTop = () => {
scrollRef.current?.scrollTo(0, 0);
};
const getMoreItem = async () => {
...
};
useEffect(() => {
...
fetchAutocompleteWords();
}, [inputText]);
return {
isSearching,
recommendList,
hasNextPage,
isFirstSearch,
scrollRef,
getMoreItem,
};
};
export default useSearch;
-
ํด๋น ์์ดํ ์ ๊ฒ์ํ ํค์๋๋ฅผ ๋ฝ์๋ด๋ ์ ํธ ํจ์
splitTextWithKeyword
๋ฅผ ๋ง๋ค์์ต๋๋ค.// src/utils/splitTextWithKeyword.ts export const splitTextWithKeyword = (text: string, keyword: string) => { const splitKey = '@#$%^'; return text .replaceAll(keyword, `${splitKey}${keyword}${splitKey}`) .split(splitKey); };
-
ํด๋น ์ ํธํจ์๋ฅผ ํตํด, api๋ฅผ ํตํด ๋ฐ์์จ ์์ดํ ๋ค์ด ๊ฒ์ ํค์๋์ ๋์ผํ ๊ฒฝ์ฐ ์คํ์ผ๋ง์ ํด์ฃผ๋ ๊ธฐ๋ฅ์ ๊ตฌํํ์ต๋๋ค.
// src/components/DropdownItem/index.tsx <li> {splitTextWithKeyword(searchWord, keyword).map((text, index) => ( <span key={index} className={text === keyword ? styles.accent_text : ''}> {text} </span> ))} </li>
- ๊ฒ์์ด๊ฐ ๋ณ๊ฒฝ์ api ์๋ต ๊ฒฐ๊ณผ๋ฅผ ์ด๊ธฐ ์ํ๋ก ์ค์ ํ๊ณ ๊ฒ์์ด ๋ฆฌ์คํธ๋ฅผ ๋ณ๊ฒฝํ๋
getMoreItem
ํจ์๋ฅผ ๋ง๋ค์ด ์ฌ์ฉํจ์ผ๋ก์จ ๋๋กญ๋ค์ด์ด ์ ์ง๋ ์ ์๋๋ก ๋ณ๊ฒฝํ์ต๋๋ค.
// src/hooks/useSearch.ts
const getMoreItem = async () => {
if (!hasNextPage) return;
setIsSearching(true);
try {
const trimmedText = inputText.trim().toLowerCase();
const response = await getSearchedList(trimmedText, nextPage);
const { limit, page, qty, total, result } = response;
setRecommendList((prev) => [...prev, ...result]);
if (limit * (page - DEFAULT_PAGE) + qty >= total) setHasNextPage(false);
setNextPage((prev) => prev + 1);
} catch (error) {
console.error(error);
alert('Something went wrong.');
} finally {
setIsSearching(false);
}
};
- ๊ฒ์์ด ๋ฆฌ์คํธ๊ฐ ๋ณ๊ฒฝ ๋ ๋ ๊ธฐ์กด ์คํฌ๋กค ์์น๊ฐ ์ ์ง๋๋ ํ์์ด ์์ด ์คํฌ๋กค ์์น๋ฅผ ์ด๊ธฐํ ํ ์ ์๋๋ก ๋ณ๊ฒฝํ์ต๋๋ค.
useSearch
hook์์ returnํscrollRef
๋ฅผ ๋๋กญ๋ค์ด์ ref๊ฐ์ผ๋ก ๋ฃ์ด์ฃผ์์ต๋๋ค.- APIํธ์ถ ์
scrollToTop
๋ฅผ ์คํ ํด ์คํฌ๋กค์ ์ต์๋จ์ผ๋ก ์ฎ๊ฒจ์ฃผ์์ต๋๋ค.
// src/components/Dropdown/index.tsx
<ul className={styles.dropdown} ref={scrollRef}>
~
</ul>
// src/hooks/useSearch.ts
const scrollRef = useRef<HTMLUListElement>(null);
const scrollToTop = () => {
scrollRef.current?.scrollTo(0, 0);
};
useEffect(() => {
const fetchAutocompleteWords = async () => {
~
try {
setIsSearching(true);
const response = await getSearchedList(trimmedText);
const { limit, page, qty, total, result } = response;
if (limit * (page - DEFAULT_PAGE) + qty < total) setHasNextPage(true);
scrollToTop();
setRecommendList(result);
}
~
};
fetchAutocompleteWords();
}, [inputText]);
useToggleModal
hook์ ์ด์ฉํ์ฌ ๋๋กญ๋ค์ด ํ ๊ธ์ ๊ตฌํํ์ต๋๋ค.- ํ ๊ธํ ์์๋ฅผ ๊ฐ๋ฅดํค๋
target
๊ณผ ๋ชจ๋ฌ์ด ํ์ฑํ๋ ์ํ์ธ์ง boolean๊ฐ์ ๋ฐํํ๋isModalOpen
์ ๋ฐํํฉ๋๋ค. !target.contains(e.target as Node)
์์ ํ์ฌ ํด๋ฆญํ ์์๊ฐ ํ ๊ธํ ์์(target)์์ ํฌํจ๋ ๋ ธ๋ ๊ฐ์ฒด์ธ์ง ํ์ธํ์ฌ ๋ชจ๋ฌ ํ ๊ธ ์ํ ์ฌ๋ถ๋ฅผ ๊ฒฐ์ ํฉ๋๋ค.
// src/hooks/useToggleModal.ts
import { useEffect, useState } from 'react';
type ReturnType = {
isModalOpen: boolean;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setTarget: React.Dispatch<
React.SetStateAction<HTMLElement | null | undefined>
>;
};
const useToggleModal = (): ReturnType => {
const [target, setTarget] = useState<HTMLElement | null | undefined>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
if (!target) return;
const handleCloseModal = (e: Event | React.MouseEvent) => {
if (isModalOpen && (!target || !target.contains(e.target as Node)))
setIsModalOpen(false);
};
window.addEventListener('mousedown', handleCloseModal);
return () => {
window.removeEventListener('mousedown', handleCloseModal);
};
}, [target, isModalOpen]);
export default useToggleModal;
- ๋ฌดํ์คํฌ๋กค์ ๊ตฌํํ๊ธฐ์ํด ์คํฌ๋กค ์ด๋ฒคํธ๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ, ๊ณผ๋ํ ์ด๋ฒคํธ์ ํธ์ถ๋ก ์ฑ๋ฅ ์ ํ์ ์ฐ๋ ค๊ฐ ์์์ต๋๋ค.
- ๋ฐ๋ผ์ Intersection Observer API๋ฅผ ์ฌ์ฉํ์ฌ ๋ถํ์ํ ์ด๋ฒคํธ ๋ฐ์ ์์ด ๋ฌดํ์คํฌ๋กค์ ๊ตฌํํ์ต๋๋ค.
- IntersectionObserver API๋ฅผ ์ฌ์ฉํ๋ ์ปค์คํ hook์ ์ด์ฉํ์ฌ 10๋ฒ์งธ์ธ ๋ง์ง๋ง ์์ดํ ์ด ๊ฐ์ง๋์์ ๋ API๋ฅผ ํธ์ถํฉ๋๋ค.
์คํฌ๋กค์ ๊ฐ์งํ๋ ์์ญ์ useIntersectionObserver
hook์ ์ด์ฉํ์ฌ ์ค์ ํฉ๋๋ค. ๋ง์ฝ ๊ฒ์ ์ค์ด ์๋๊ณ ๋ง์ง๋ง ์์ดํ
์ ์คํฌ๋กค ์ค์ด๋ผ๋ฉด search API๋ฅผ ํธ์ถํฉ๋๋ค.
// src/components/DropDown.ts
const onIntersect: IntersectionObserverCallback = ([{ isIntersecting }]) => {
if (isSearching) return;
if (isIntersecting) getMoreItem();
};
const { setTarget } = useIntersectionObserver({ onIntersect });
return(
...
{!isSearching && hasNextPage && (
<span
className={`${styles.align_center} ${styles.ellipsis}`}
ref={setTarget}
>
...
</span>
)}
)
์ปค์คํ hook์ ์ฌ์ฉํ์ฌ ์คํฌ๋กค์ ๊ฐ์งํ๋ ์ฝ๋๋ฅผ UI ์ปดํฌ๋ํธ์ ๋ถ๋ฆฌํ๊ณ options๋ฅผ ์ธ์๋ก ๋ฐ์์ ์ฌ์ฌ์ฉ์ฑ์ ๋์์ต๋๋ค.
// src/components/DropDown.ts
const useIntersectionObserver = ({
root,
rootMargin = '0px',
threshold = 1,
onIntersect,
}: useIntersectionObserverProps) => {
const [target, setTarget] = useState<HTMLElement | null | undefined>(null);
useEffect(() => {
if (!target) return;
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect,
{ root, rootMargin, threshold },
);
observer.observe(target);
return () => observer.unobserve(target);
}, [onIntersect, root, rootMargin, target, threshold]);
return { setTarget };
};
export default useIntersectionObserver;
์๋ฒ์์ ์ ๋ฌํ๋ ๋ฐ์ดํฐ total ๊ฐ์์ ํ์ฌ์ ๋ฐ์ดํฐ ๊ฐ์๋ฅผ ๋น๊ตํ์ฌ ๋ ์ด์ ๋ฐ์์ฌ ๋ฐ์ดํฐ๊ฐ ์๋ค๋ฉด API๋ฅผ ํธ์ถํ์ง ์๋๋ก ๊ตฌํํ์ต๋๋ค.
// src/hooks/useSearch.ts
const { limit, page, qty, total, result } = response;
if (limit * (page - DEFAULT_PAGE) + qty >= total) setHasNextPage(false);
// src/components/DropDown.ts
return(
...
{!isSearching && hasNextPage && (
<span
className={`${styles.align_center} ${styles.ellipsis}`}
ref={setTarget}
>
...
</span>
)}
)
- ๊ธฐ์กด์ ์ฌ์ฉ๋๊ณ ์๋
useFocus
์ปค์คํ ํ ์ ๋์ฒดํ์ฌ ์ถ๊ฐํautoFocus
์์ฑ์ ๊ฒ์ฆํ๊ธฐ ์ํด ์ฒซ ๋ ๋๋ง ์ ์๋ ํฌ์ปค์ค ์ฌ๋ถ๋ฅผ ํ ์คํธํ์ต๋๋ค.
// src/__tests__/inputTodo.test.tsx
expect(screen.getByTestId('input-text')).toHaveFocus();
- input์ ์ ๋ ฅ๋ ์ ๋ ฅ๊ฐ์ ์ธ์๋ก ํ๋ ๋๋ฐ์ด์ฑ ์ปค์คํ ํ ํธ์ถ ์ฌ๋ถ๋ฅผ ํ ์คํธํ์ต๋๋ค.
// src/__tests__/inputTodo.test.tsx
test('input ์
๋ ฅ ์ ์
๋ ฅ๊ฐ์ผ๋ก ๋๋ฐ์ด์ฑ ์คํ๋จ', () => {
const inputText = screen.getByTestId('input-text');
const INPUT = 'lorem';
userEvent.type(inputText, INPUT);
expect(useDebounce).toHaveBeenCalledWith(INPUT);
});
- API ํธ์ถ์ ํตํด ๋ฐ์์ค๋ ์ถ์ฒ ๊ฒ์์ด ๋ฐฐ์ด๊ณผ ์ค์ ๋ ๋๋ง๋๋ ๋๋กญ๋ค์ด ๋ฆฌ์คํธ์ ์ผ์น ์ฌ๋ถ๋ฅผ ํ ์คํธํ์ต๋๋ค.
// src/__tests__/dropdown.test.tsx
expect(dropdownItems.length).toBe(DropdownProps.recommendList.length);
- ๋ชจ๋ ์ถ์ฒ ๊ฒ์์ด ๋ฆฌ์คํธ ์ค
input
์ ๋ ฅ๊ฐ๊ณผ ์ผ์นํ๋ ๋ชจ๋ ๋ฌธ์์ด์ ๊ธ์์ ๊ฐ์กฐ ์คํ์ผ๋ง ์ ์ฉ ์ฌ๋ถ๋ฅผ ํ ์คํธํ์ต๋๋ค.
// src/__tests__/dropdown.test.tsx
test('๋ชจ๋ ์ถ์ฒ ๊ฒ์์ด ๋ฆฌ์คํธ์์ ์
๋ ฅ๊ฐ๊ณผ ์ผ์นํ๋ ๋ฌธ์์ด์ ๊ธ์์ ๊ฐ์กฐ ์คํ์ผ๋ง ์ ์ฉ', () => {
const keywords = screen.getAllByText(DropdownProps.keyword);
const accentTexts = screen.getAllByTestId('accent-text');
expect(keywords).toMatchObject(accentTexts);
});