From 261b4ac6f9779f5e48d143b3cfb7b335de8f7a71 Mon Sep 17 00:00:00 2001 From: BangDori <44726494+BangDori@users.noreply.github.com> Date: Fri, 10 May 2024 18:54:18 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?API=20=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 좋아요 버튼 테두리 색상값 수정 * feat: 좋아요 버튼 분리 * feat: 아이콘 활성화 색상 상수 분리 * feat: FetchFeeds 타입 분리 * feat: 피드 좋아요 API * fix: 옵저버 위치 수정 * feat: 서버 성공 응답 확인 함수 * feat: date 관련 공용 함수 public api 처리 * feat: 좋아요 API 부수 기능 추가 * feat: isErrorResponse로 수정 * feat: 서버 데이터 처리 실패 시 쿼리 롤백 * feat: 좋아요 API 관련 조건부 추가 * feat: useLike public api 추가 * feat: 좋아요 취소 API * feat: 커스텀 훅 테스트를 위한 Wrapper 생성 * feat: 좋아요 상태 업데이트 함수 분리 * feat: 주석 오타 수정 * feat: 좋아요 및 좋아요 취소 API 분리 * style: 주석 수정 * test: 좋아요 및 좋아요 취소 API 호출 확인 * style: 주석 수정 * feat: useLike -> useLikes 훅스 명 변경 Closes #PW-297 --- public/assets/sprites/common.svg | 2 +- src/app/mocks/consts/error.ts | 1 + src/app/mocks/handler/like.ts | 26 ++++---- src/features/.gitkeep | 0 src/features/feed-main-like/api/index.ts | 1 + src/features/feed-main-like/api/useLikes.tsx | 60 +++++++++++++++++++ src/features/feed-main-like/consts/index.ts | 6 ++ src/features/feed-main-like/index.ts | 1 + src/features/feed-main-like/lib/index.ts | 27 +++++++++ .../feed-main-like/test/useLikes.test.tsx | 43 +++++++++++++ src/features/feed-main-like/ui/LikeButton.tsx | 28 +++++++++ src/features/feed-main-like/ui/index.ts | 1 + src/features/index.ts | 1 + src/shared/axios/index.ts | 1 + src/shared/axios/like/index.ts | 23 +++++++ src/shared/consts/color/color.ts | 1 + src/shared/consts/color/index.ts | 1 + src/shared/consts/index.ts | 1 + src/shared/consts/types/feed.ts | 11 ++++ src/shared/consts/types/index.ts | 2 +- src/shared/tests/setup.tsx | 27 +++++++-- src/shared/ui/icon/ui/Icon.tsx | 5 +- src/shared/utils/date/index.ts | 1 + src/shared/utils/index.ts | 3 +- src/shared/utils/response/index.ts | 1 + src/shared/utils/response/response.ts | 8 +++ .../feed-main-list/api/useInfinityFeeds.tsx | 13 +--- src/widgets/feed-main-list/ui/Feed.tsx | 8 +-- .../feed-main-list/ui/FeedMainList.tsx | 10 ++-- 29 files changed, 274 insertions(+), 39 deletions(-) delete mode 100644 src/features/.gitkeep create mode 100644 src/features/feed-main-like/api/index.ts create mode 100644 src/features/feed-main-like/api/useLikes.tsx create mode 100644 src/features/feed-main-like/consts/index.ts create mode 100644 src/features/feed-main-like/index.ts create mode 100644 src/features/feed-main-like/lib/index.ts create mode 100644 src/features/feed-main-like/test/useLikes.test.tsx create mode 100644 src/features/feed-main-like/ui/LikeButton.tsx create mode 100644 src/features/feed-main-like/ui/index.ts create mode 100644 src/features/index.ts create mode 100644 src/shared/axios/like/index.ts create mode 100644 src/shared/consts/color/color.ts create mode 100644 src/shared/consts/color/index.ts create mode 100644 src/shared/utils/date/index.ts create mode 100644 src/shared/utils/response/index.ts create mode 100644 src/shared/utils/response/response.ts diff --git a/public/assets/sprites/common.svg b/public/assets/sprites/common.svg index ad0924a..513aa38 100644 --- a/public/assets/sprites/common.svg +++ b/public/assets/sprites/common.svg @@ -58,7 +58,7 @@ { + const queryClient = useQueryClient(); + + const { mutate: handleLikeFeed, isPending } = useMutation({ + mutationFn: () => + isLiked ? requestUnlikeFeed(feedId) : requestLikeFeed(feedId), + // mutate가 호출되면 ✨낙관적 업데이트를 위해 onMutate를 실행 + onMutate: async () => { + // 진행중인 refetch가 있다면 취소시킨다. + await queryClient.cancelQueries({ + queryKey: [QUERY_KEYS.feeds], + }); + + // 이전 쿼리값의 스냅샷 + const previousQueryData = queryClient.getQueryData([ + QUERY_KEYS.feeds, + ]); + + if (!previousQueryData) return; + + // 업데이트 될 쿼리값 + const updatedQueryData = updateLikeStatusInFeeds( + previousQueryData, + feedId, + ); + + // setQueryData 함수를 사용해 newTodo로 Optimistic Update를 실시한다. + await queryClient.setQueryData([QUERY_KEYS.feeds], updatedQueryData); + + return { previousQueryData }; + }, + onError: (_, __, context) => { + // Network Error일 경우 이전 쿼리값으로 롤백 + queryClient.setQueryData([QUERY_KEYS.feeds], context?.previousQueryData); + }, + onSuccess: (response, _, context) => { + // Nextwork Success일 경우 실행 + + if (isErrorResponse(response)) { + // 실패 시 이전 쿼리값으로 롤백 + queryClient.setQueryData([QUERY_KEYS.feeds], context.previousQueryData); + return; + } + + // 성공 시 피드 아이디에 해당하는 피드를 무효화한다. + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.feed, feedId] }); + }, + }); + + return { handleLikeFeed, isPending }; +}; diff --git a/src/features/feed-main-like/consts/index.ts b/src/features/feed-main-like/consts/index.ts new file mode 100644 index 0000000..29e00a8 --- /dev/null +++ b/src/features/feed-main-like/consts/index.ts @@ -0,0 +1,6 @@ +import { FetchFeeds } from '@/shared/consts'; + +export interface FeedsQueryData { + queryParams: number[]; + pages: FetchFeeds[]; +} diff --git a/src/features/feed-main-like/index.ts b/src/features/feed-main-like/index.ts new file mode 100644 index 0000000..7f4d6b7 --- /dev/null +++ b/src/features/feed-main-like/index.ts @@ -0,0 +1 @@ +export { LikeButton } from './ui'; diff --git a/src/features/feed-main-like/lib/index.ts b/src/features/feed-main-like/lib/index.ts new file mode 100644 index 0000000..ed81a27 --- /dev/null +++ b/src/features/feed-main-like/lib/index.ts @@ -0,0 +1,27 @@ +import { FeedsQueryData } from '../consts'; + +export function updateLikeStatusInFeeds( + previousQueryData: FeedsQueryData, + feedId: number, +) { + const { pages: previousPages } = previousQueryData; + + return { + ...previousQueryData, + pages: previousPages.map((pageData) => { + const { data } = pageData; + const updateFeeds = data.feeds.map((feed) => + feed.id === feedId + ? { + ...feed, + likeCount: feed.isLiked ? feed.likeCount - 1 : feed.likeCount + 1, + isLiked: !feed.isLiked, + } + : feed, + ); + const updatedData = { ...data, feeds: updateFeeds }; + + return { ...pageData, data: updatedData }; + }), + }; +} diff --git a/src/features/feed-main-like/test/useLikes.test.tsx b/src/features/feed-main-like/test/useLikes.test.tsx new file mode 100644 index 0000000..540a832 --- /dev/null +++ b/src/features/feed-main-like/test/useLikes.test.tsx @@ -0,0 +1,43 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import * as likeModule from '@/shared/axios/like'; +import { createQueryClientWrapper } from '@/shared/tests/setup'; + +import { useLikes } from '../api'; + +test('좋아요 상태가 아닐 때, 좋아요 버튼을 클릭하면 좋아요 요청이 발생한다.', async () => { + // given + // requestLikeFeed 함수를 스파이한다. + const spy = vi.spyOn(likeModule, 'requestLikeFeed'); + const { result } = renderHook(() => useLikes(1, false), { + wrapper: createQueryClientWrapper(), + }); + + // requestLikeFeed가 호출되지 않았는지 확인 + await waitFor(() => expect(spy).not.toHaveBeenCalled()); + + // when + // 좋아요 버튼 클릭 + await act(async () => result.current.handleLikeFeed()); + + // then + // requestLikeFeed가 호출되었는지 확인 + await waitFor(() => expect(spy).toHaveBeenCalled()); +}); + +test('좋아요 상태일 때, 좋아요 버튼을 클릭하면 좋아요 취소 요청이 발생한다.', async () => { + // given + const spy = vi.spyOn(likeModule, 'requestUnlikeFeed'); + const { result } = renderHook(() => useLikes(1, true), { + wrapper: createQueryClientWrapper(), + }); + + await waitFor(() => expect(spy).not.toHaveBeenCalled()); + + // when + await act(async () => result.current.handleLikeFeed()); + + // then + await waitFor(() => expect(spy).toHaveBeenCalled()); +}); diff --git a/src/features/feed-main-like/ui/LikeButton.tsx b/src/features/feed-main-like/ui/LikeButton.tsx new file mode 100644 index 0000000..131bdab --- /dev/null +++ b/src/features/feed-main-like/ui/LikeButton.tsx @@ -0,0 +1,28 @@ +import { ICON_ACTIVE_COLOR } from '@/shared/consts'; +import { Icon } from '@/shared/ui'; + +import { useLikes } from '../api'; + +interface LikeButtonProps { + feedId: number; + isLiked: boolean; +} + +export const LikeButton: React.FC = ({ feedId, isLiked }) => { + const { handleLikeFeed, isPending } = useLikes(feedId, isLiked); + + return ( + + ); +}; diff --git a/src/features/feed-main-like/ui/index.ts b/src/features/feed-main-like/ui/index.ts new file mode 100644 index 0000000..09ac69b --- /dev/null +++ b/src/features/feed-main-like/ui/index.ts @@ -0,0 +1 @@ +export { LikeButton } from './LikeButton'; diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..ebed232 --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1 @@ +export { LikeButton } from './feed-main-like'; diff --git a/src/shared/axios/index.ts b/src/shared/axios/index.ts index 859917e..108460a 100644 --- a/src/shared/axios/index.ts +++ b/src/shared/axios/index.ts @@ -1 +1,2 @@ export { axiosInstance } from './config/instance'; +export * from './like'; diff --git a/src/shared/axios/like/index.ts b/src/shared/axios/like/index.ts new file mode 100644 index 0000000..44454f3 --- /dev/null +++ b/src/shared/axios/like/index.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '../config/instance'; + +/** + * 좋아요 API + * @param feedId 피드 아이디 + * @returns 좋아요 상태 + */ +export async function requestLikeFeed(feedId: number) { + const { data } = await axiosInstance.put(`/feeds/${feedId}/likes`); + + return data; +} + +/** + * 좋아요 취소 API + * @param feedId 피드 아이디 + * @returns 좋아요 상태 + */ +export async function requestUnlikeFeed(feedId: number) { + const { data } = await axiosInstance.delete(`/feeds/${feedId}/likes`); + + return data; +} diff --git a/src/shared/consts/color/color.ts b/src/shared/consts/color/color.ts new file mode 100644 index 0000000..fd8326b --- /dev/null +++ b/src/shared/consts/color/color.ts @@ -0,0 +1 @@ +export const ICON_ACTIVE_COLOR = '#00D5E1'; diff --git a/src/shared/consts/color/index.ts b/src/shared/consts/color/index.ts new file mode 100644 index 0000000..64d2585 --- /dev/null +++ b/src/shared/consts/color/index.ts @@ -0,0 +1 @@ +export * from './color'; diff --git a/src/shared/consts/index.ts b/src/shared/consts/index.ts index 383f0ab..783d4c4 100644 --- a/src/shared/consts/index.ts +++ b/src/shared/consts/index.ts @@ -1,2 +1,3 @@ export { QUERY_KEYS } from './query-key/queryKeys'; export type * from './types'; +export * from './color'; diff --git a/src/shared/consts/types/feed.ts b/src/shared/consts/types/feed.ts index 1c069b7..4304080 100644 --- a/src/shared/consts/types/feed.ts +++ b/src/shared/consts/types/feed.ts @@ -1,5 +1,16 @@ import { User } from '.'; +export interface FetchFeeds { + code: string; + data: { + currentPageNumber: number; + feeds: Feed[]; + hasNextPage: boolean; + numberOfElements: number; + pageSize: number; + }; +} + export interface Feed { id: number; user: Pick; diff --git a/src/shared/consts/types/index.ts b/src/shared/consts/types/index.ts index 0b50504..c3b5bb5 100644 --- a/src/shared/consts/types/index.ts +++ b/src/shared/consts/types/index.ts @@ -1,4 +1,4 @@ -export type { Feed, Image } from './feed'; +export type * from './feed'; export type { Comment } from './comment'; export type { Like } from './like'; export type { User, RelationshipStatus } from './user'; diff --git a/src/shared/tests/setup.tsx b/src/shared/tests/setup.tsx index 0d5194b..09449d0 100644 --- a/src/shared/tests/setup.tsx +++ b/src/shared/tests/setup.tsx @@ -1,23 +1,40 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render as testRender } from '@testing-library/react'; -import { ReactElement } from 'react'; +import { PropsWithChildren, ReactElement } from 'react'; import { MemoryRouter } from 'react-router-dom'; -function customRender(children: ReactElement, baseEntries?: string[]) { - const queryClient = new QueryClient({ +const generateQueryClient = () => { + const queryClientOptions = { defaultOptions: { queries: { retry: false, }, }, - }); + }; + + return new QueryClient(queryClientOptions); +}; + +// hooks를 사용하는 컴포넌트를 테스트할 때 필요한 wrapper를 생성하는 함수 +// from https://tkdodo.eu/blog/testing-react-query#for-custom-hooks +export const createQueryClientWrapper = () => { + const queryClient = generateQueryClient(); + return ({ children }: PropsWithChildren) => ( + {children} + ); +}; + +// custom render 함수 +// reference: https://testing-library.com/docs/react-testing-library/setup#custom-render +const customRender = (children: ReactElement, baseEntries?: string[]) => { + const queryClient = generateQueryClient(); return testRender( {children} , ); -} +}; // eslint-disable-next-line react-refresh/only-export-components export * from '@testing-library/react'; diff --git a/src/shared/ui/icon/ui/Icon.tsx b/src/shared/ui/icon/ui/Icon.tsx index de10109..59668fd 100644 --- a/src/shared/ui/icon/ui/Icon.tsx +++ b/src/shared/ui/icon/ui/Icon.tsx @@ -10,7 +10,10 @@ interface IconProps { export const Icon: React.FC = ({ name, width, height, color }) => { return ( - + ); }; diff --git a/src/shared/utils/date/index.ts b/src/shared/utils/date/index.ts new file mode 100644 index 0000000..05b562f --- /dev/null +++ b/src/shared/utils/date/index.ts @@ -0,0 +1 @@ +export * from './date'; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index a534e8a..243ae21 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -1 +1,2 @@ -export * from './date/date'; +export * from './date'; +export * from './response'; diff --git a/src/shared/utils/response/index.ts b/src/shared/utils/response/index.ts new file mode 100644 index 0000000..dbc1ea0 --- /dev/null +++ b/src/shared/utils/response/index.ts @@ -0,0 +1 @@ +export * from './response'; diff --git a/src/shared/utils/response/response.ts b/src/shared/utils/response/response.ts new file mode 100644 index 0000000..26c7be6 --- /dev/null +++ b/src/shared/utils/response/response.ts @@ -0,0 +1,8 @@ +interface Response { + code: string; + data: unknown; +} + +export function isErrorResponse(response: Response) { + return response.code !== '2000'; +} diff --git a/src/widgets/feed-main-list/api/useInfinityFeeds.tsx b/src/widgets/feed-main-list/api/useInfinityFeeds.tsx index 4b1cb47..ba997fd 100644 --- a/src/widgets/feed-main-list/api/useInfinityFeeds.tsx +++ b/src/widgets/feed-main-list/api/useInfinityFeeds.tsx @@ -1,18 +1,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { axiosInstance } from '@/shared/axios'; -import { Feed, QUERY_KEYS } from '@/shared/consts'; - -interface FetchFeeds { - code: string; - data: { - currentPageNumber: number; - feeds: Feed[]; - hasNextPage: boolean; - numberOfElements: number; - pageSize: number; - }; -} +import { FetchFeeds, QUERY_KEYS } from '@/shared/consts'; async function fetchFeeds( page: number, diff --git a/src/widgets/feed-main-list/ui/Feed.tsx b/src/widgets/feed-main-list/ui/Feed.tsx index 9093645..22c1a33 100644 --- a/src/widgets/feed-main-list/ui/Feed.tsx +++ b/src/widgets/feed-main-list/ui/Feed.tsx @@ -1,3 +1,4 @@ +import { LikeButton } from '@/features'; import { Feed as FeedProps } from '@/shared/consts'; import { Icon, Profile } from '@/shared/ui'; import { calculateElapsedTime } from '@/shared/utils'; @@ -5,7 +6,8 @@ import { calculateElapsedTime } from '@/shared/utils'; import './Feed.scss'; export const Feed: React.FC<{ feed: FeedProps }> = ({ feed }) => { - const { user, content, likeCount, commentCount, updatedAt } = feed; + const { id, user, content, likeCount, commentCount, updatedAt, isLiked } = + feed; return (
@@ -31,9 +33,7 @@ export const Feed: React.FC<{ feed: FeedProps }> = ({ feed }) => {