diff --git a/src/app/mocks/browser.ts b/src/app/mocks/browser.ts index e4a9dcb..d0d6e01 100644 --- a/src/app/mocks/browser.ts +++ b/src/app/mocks/browser.ts @@ -6,11 +6,13 @@ import { likeHandlers } from './handler/like'; import { followHandler } from './handler/follow'; import { searchHandler } from './handler/search'; import { userHandler } from './handler/user'; +import { bookmarkHandlers } from './handler/bookmark'; // 브라우저에서 실행하기 위한 mocking worker 초기화 export const worker = setupWorker( ...commentHandlers, ...feedHandlers, + ...bookmarkHandlers, ...likeHandlers, ...followHandler, ...searchHandler, diff --git a/src/app/mocks/consts/feed.ts b/src/app/mocks/consts/feed.ts index 948aa4e..52ca7be 100644 --- a/src/app/mocks/consts/feed.ts +++ b/src/app/mocks/consts/feed.ts @@ -61,7 +61,7 @@ export const feeds: Feeds = { commentCount: comments[1].length, isLiked: likes[1].isLiked, - isBookmark: false, + isBookmarked: true, createdAt: '2024-04-16 12:00:00', updatedAt: '2024-04-16 12:00:00', @@ -76,7 +76,7 @@ export const feeds: Feeds = { commentCount: comments[2].length, isLiked: likes[2].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 12:10:00', updatedAt: '2024-04-16 12:10:00', @@ -104,7 +104,7 @@ export const feeds: Feeds = { commentCount: comments[3].length, isLiked: likes[3].isLiked, - isBookmark: false, + isBookmarked: true, createdAt: '2024-04-16 12:20:00', updatedAt: '2024-04-16 12:20:00', @@ -140,7 +140,7 @@ export const feeds: Feeds = { commentCount: comments[4].length, isLiked: likes[4].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 12:30:00', updatedAt: '2024-04-16 12:30:00', @@ -168,7 +168,7 @@ export const feeds: Feeds = { commentCount: comments[5].length, isLiked: likes[5].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 12:40:00', updatedAt: '2024-04-16 12:40:00', @@ -196,7 +196,7 @@ export const feeds: Feeds = { commentCount: comments[6].length, isLiked: likes[6].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 12:50:00', updatedAt: '2024-04-16 12:50:00', @@ -220,7 +220,7 @@ export const feeds: Feeds = { commentCount: comments[7].length, isLiked: likes[7].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 13:00:00', updatedAt: '2024-04-16 13:00:00', @@ -240,7 +240,7 @@ export const feeds: Feeds = { commentCount: comments[8].length, isLiked: likes[8].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 13:10:00', updatedAt: '2024-04-16 13:10:00', @@ -264,7 +264,7 @@ export const feeds: Feeds = { commentCount: comments[9].length, isLiked: likes[9].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-04-16 13:20:00', updatedAt: '2024-04-16 13:20:00', @@ -285,7 +285,7 @@ for (let i = 10; i < 100; i++) { commentCount: comments[i].length, isLiked: likes[i].isLiked, - isBookmark: false, + isBookmarked: false, createdAt: '2024-05-03 12:00:00', updatedAt: '2024-05-03 12:00:00', diff --git a/src/app/mocks/handler/bookmark.ts b/src/app/mocks/handler/bookmark.ts new file mode 100644 index 0000000..67d49d1 --- /dev/null +++ b/src/app/mocks/handler/bookmark.ts @@ -0,0 +1,47 @@ +import { http } from 'msw'; + +import { + createHttpErrorResponse, + createHttpSuccessResponse, +} from '../dir/response'; +import { feeds } from '../consts/feed'; + +export const bookmarkHandlers = [ + // 1️⃣ 피드 북마크 + http.put('/feeds/:feed_id/bookmarks', ({ params }) => { + const { feed_id } = params; + + if (isNaN(Number(feed_id))) { + return createHttpErrorResponse('4220'); + } + + const formattedFeedId = Number(feed_id); + + if (!feeds[formattedFeedId]) { + return createHttpErrorResponse('4040'); + } + + feeds[formattedFeedId].isBookmarked = true; + + return createHttpSuccessResponse({ isBookmarked: true }); + }), + + // 2️⃣ 피드 북마크 취소 + http.delete('/feeds/:feed_id/bookmarks', ({ params }) => { + const { feed_id } = params; + + if (isNaN(Number(feed_id))) { + return createHttpErrorResponse('4220'); + } + + const formattedFeedId = Number(feed_id); + + if (!feeds[formattedFeedId]) { + return createHttpErrorResponse('4040'); + } + + feeds[formattedFeedId].isBookmarked = false; + + return createHttpSuccessResponse({ isBookmarked: false }); + }), +]; diff --git a/src/app/mocks/handler/feed.ts b/src/app/mocks/handler/feed.ts index 75847a6..ae0e24d 100644 --- a/src/app/mocks/handler/feed.ts +++ b/src/app/mocks/handler/feed.ts @@ -25,9 +25,6 @@ interface ReportForm { export const feedHandlers = [ // 1️⃣ 피드 목록 조회 - /** - * @todo pageCount를 쿼리 파라미터로 받도록 수정 - */ http.get('/feeds', ({ request }) => { const url = new URL(request.url); const page = url.searchParams.get('page') || 1; @@ -77,7 +74,7 @@ export const feedHandlers = [ commentCount: 0, isLiked: false, - isBookmark: false, + isBookmarked: false, createdAt: getCurrentDate(), updatedAt: getCurrentDate(), diff --git a/src/features/feed-bookmark/api/index.ts b/src/features/feed-bookmark/api/index.ts new file mode 100644 index 0000000..a51727e --- /dev/null +++ b/src/features/feed-bookmark/api/index.ts @@ -0,0 +1 @@ +export { useBookmarks } from './useBookmarks'; diff --git a/src/features/feed-bookmark/api/useBookmarks.tsx b/src/features/feed-bookmark/api/useBookmarks.tsx new file mode 100644 index 0000000..727a922 --- /dev/null +++ b/src/features/feed-bookmark/api/useBookmarks.tsx @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { requestBookmarkFeed, requestUnbookmarkFeed } from '@/shared/axios'; +import { FeedsQueryData } from '@/shared/consts'; +import { QUERY_KEYS } from '@/shared/react-query'; +import { isErrorResponse } from '@/shared/utils'; + +import { updateBookmarkStatusInFeeds } from '../lib'; + +export const useBookmarks = (feedId: number, isBookmarked: boolean) => { + const queryClient = useQueryClient(); + + const { mutate: handleBookmarkFeed, isPending } = useMutation({ + mutationFn: () => + isBookmarked + ? requestUnbookmarkFeed(feedId) + : requestBookmarkFeed(feedId), + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: [QUERY_KEYS.feeds], + }); + + // 이전 쿼리값의 스냅샷 + const previousQueryData = queryClient.getQueryData([ + QUERY_KEYS.feeds, + ]); + + if (!previousQueryData) return; + + // 업데이트 될 쿼리값 + const updatedQueryData = updateBookmarkStatusInFeeds( + previousQueryData, + feedId, + ); + + // setQueryData 함수를 사용해 newTodo로 Optimistic Update를 실시한다. + await queryClient.setQueryData([QUERY_KEYS.feeds], updatedQueryData); + + return { previousQueryData }; + }, + onError: (_, __, context) => { + queryClient.setQueryData([QUERY_KEYS.feeds], context?.previousQueryData); + }, + onSuccess: (response, _, context) => { + if (isErrorResponse(response)) { + queryClient.setQueryData([QUERY_KEYS.feeds], context.previousQueryData); + return; + } + + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.feed, feedId] }); + }, + }); + + return { handleBookmarkFeed, isPending }; +}; diff --git a/src/features/feed-bookmark/index.ts b/src/features/feed-bookmark/index.ts new file mode 100644 index 0000000..6b54581 --- /dev/null +++ b/src/features/feed-bookmark/index.ts @@ -0,0 +1 @@ +export { BookmarkButton } from './ui'; diff --git a/src/features/feed-bookmark/lib/index.ts b/src/features/feed-bookmark/lib/index.ts new file mode 100644 index 0000000..08526ff --- /dev/null +++ b/src/features/feed-bookmark/lib/index.ts @@ -0,0 +1,26 @@ +import { FeedsQueryData } from '@/shared/consts'; + +export function updateBookmarkStatusInFeeds( + 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, + isBookmarked: !feed.isBookmarked, + } + : feed, + ); + const updatedData = { ...data, feeds: updateFeeds }; + + return { ...pageData, data: updatedData }; + }), + }; +} diff --git a/src/features/feed-bookmark/test/useBookmarks.test.tsx b/src/features/feed-bookmark/test/useBookmarks.test.tsx new file mode 100644 index 0000000..7da5b97 --- /dev/null +++ b/src/features/feed-bookmark/test/useBookmarks.test.tsx @@ -0,0 +1,43 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import * as bookmarkModule from '@/shared/axios'; +import { createQueryClientWrapper } from '@/shared/tests'; + +import { useBookmarks } from '../api'; + +test('북마크 상태가 아닐 때, 북마크 버튼을 클릭하면 북마크 요청이 발생한다.', async () => { + // given + // requestBookmarkFeed 함수를 스파이한다. + const spy = vi.spyOn(bookmarkModule, 'requestBookmarkFeed'); + const { result } = renderHook(() => useBookmarks(1, false), { + wrapper: createQueryClientWrapper(), + }); + + // requestBookmarkFeed가 호출되지 않았는지 확인 + await waitFor(() => expect(spy).not.toHaveBeenCalled()); + + // when + // 좋아요 버튼 클릭 + await act(async () => result.current.handleBookmarkFeed()); + + // then + // requestBookmarkFeed가 호출되었는지 확인 + await waitFor(() => expect(spy).toHaveBeenCalled()); +}); + +test('북마크 상태일 때, 북마크 버튼을 클릭하면 북마크 취소 요청이 발생한다.', async () => { + // given + const spy = vi.spyOn(bookmarkModule, 'requestUnbookmarkFeed'); + const { result } = renderHook(() => useBookmarks(1, true), { + wrapper: createQueryClientWrapper(), + }); + + await waitFor(() => expect(spy).not.toHaveBeenCalled()); + + // when + await act(async () => result.current.handleBookmarkFeed()); + + // then + await waitFor(() => expect(spy).toHaveBeenCalled()); +}); diff --git a/src/features/feed-bookmark/ui/BookmarkButton.tsx b/src/features/feed-bookmark/ui/BookmarkButton.tsx new file mode 100644 index 0000000..3547c56 --- /dev/null +++ b/src/features/feed-bookmark/ui/BookmarkButton.tsx @@ -0,0 +1,31 @@ +import { ICON_ACTIVE_COLOR } from '@/shared/consts'; +import { Icon } from '@/shared/ui'; + +import { useBookmarks } from '../api'; + +interface BookmarkButtonProps { + feedId: number; + isBookmarked: boolean; +} + +export const BookmarkButton: React.FC = ({ + feedId, + isBookmarked, +}) => { + const { handleBookmarkFeed, isPending } = useBookmarks(feedId, isBookmarked); + + return ( + + ); +}; diff --git a/src/features/feed-bookmark/ui/index.ts b/src/features/feed-bookmark/ui/index.ts new file mode 100644 index 0000000..46164d0 --- /dev/null +++ b/src/features/feed-bookmark/ui/index.ts @@ -0,0 +1 @@ +export { BookmarkButton } from './BookmarkButton'; diff --git a/src/features/feed-main-like/api/useLikes.tsx b/src/features/feed-main-like/api/useLikes.tsx index 3d46a4f..bf0a611 100644 --- a/src/features/feed-main-like/api/useLikes.tsx +++ b/src/features/feed-main-like/api/useLikes.tsx @@ -1,10 +1,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { requestLikeFeed, requestUnlikeFeed } from '@/shared/axios'; +import { FeedsQueryData } from '@/shared/consts'; import { QUERY_KEYS } from '@/shared/react-query'; import { isErrorResponse } from '@/shared/utils'; -import { FeedsQueryData } from '../consts'; import { updateLikeStatusInFeeds } from '../lib'; export const useLikes = (feedId: number, isLiked: boolean) => { diff --git a/src/features/feed-main-like/consts/index.ts b/src/features/feed-main-like/consts/index.ts deleted file mode 100644 index 29e00a8..0000000 --- a/src/features/feed-main-like/consts/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FetchFeeds } from '@/shared/consts'; - -export interface FeedsQueryData { - queryParams: number[]; - pages: FetchFeeds[]; -} diff --git a/src/features/feed-main-like/lib/index.ts b/src/features/feed-main-like/lib/index.ts index ed81a27..00c7c9b 100644 --- a/src/features/feed-main-like/lib/index.ts +++ b/src/features/feed-main-like/lib/index.ts @@ -1,4 +1,4 @@ -import { FeedsQueryData } from '../consts'; +import { FeedsQueryData } from '@/shared/consts'; export function updateLikeStatusInFeeds( previousQueryData: FeedsQueryData, diff --git a/src/shared/axios/bookmark/bookmark.ts b/src/shared/axios/bookmark/bookmark.ts new file mode 100644 index 0000000..c6a072a --- /dev/null +++ b/src/shared/axios/bookmark/bookmark.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '../config'; + +/** + * 피드 북마크 API + * @param feedId 피드 아이디 + * @returns 피드 북마크 상태 + */ +export async function requestBookmarkFeed(feedId: number) { + const { data } = await axiosInstance.put(`/feeds/${feedId}/bookmarks`); + + return data; +} + +/** + * 피드 북마크 취소 API + * @param feedId 피드 아이디 + * @returns 피드 북마크 상태 + */ +export async function requestUnbookmarkFeed(feedId: number) { + const { data } = await axiosInstance.delete(`/feeds/${feedId}/bookmarks`); + + return data; +} diff --git a/src/shared/axios/bookmark/index.ts b/src/shared/axios/bookmark/index.ts new file mode 100644 index 0000000..eff4608 --- /dev/null +++ b/src/shared/axios/bookmark/index.ts @@ -0,0 +1 @@ +export * from './bookmark'; diff --git a/src/shared/axios/index.ts b/src/shared/axios/index.ts index a2e9790..63b64b0 100644 --- a/src/shared/axios/index.ts +++ b/src/shared/axios/index.ts @@ -1,2 +1,3 @@ export { axiosInstance } from './config'; export * from './like'; +export * from './bookmark'; diff --git a/src/shared/consts/types/feed.ts b/src/shared/consts/types/feed.ts index 4304080..f66c891 100644 --- a/src/shared/consts/types/feed.ts +++ b/src/shared/consts/types/feed.ts @@ -1,5 +1,10 @@ import { User } from '.'; +export interface FeedsQueryData { + queryParams: number[]; + pages: FetchFeeds[]; +} + export interface FetchFeeds { code: string; data: { @@ -22,7 +27,7 @@ export interface Feed { commentCount: number; isLiked: boolean; - isBookmark: boolean; + isBookmarked: boolean; createdAt: string; updatedAt: string; diff --git a/src/widgets/feed-main-list/ui/Feed.tsx b/src/widgets/feed-main-list/ui/Feed.tsx index 458a20a..22818a5 100644 --- a/src/widgets/feed-main-list/ui/Feed.tsx +++ b/src/widgets/feed-main-list/ui/Feed.tsx @@ -1,3 +1,4 @@ +import { BookmarkButton } from '@/features/feed-bookmark'; import { LikeButton } from '@/features/feed-main-like'; import { Feed as FeedProps } from '@/shared/consts'; import { Carousel, Icon, Profile } from '@/shared/ui'; @@ -9,12 +10,13 @@ export const Feed: React.FC<{ feed: FeedProps }> = ({ feed }) => { const { id, user, + updatedAt, content, images, likeCount, commentCount, - updatedAt, isLiked, + isBookmarked, } = feed; return ( @@ -51,9 +53,7 @@ export const Feed: React.FC<{ feed: FeedProps }> = ({ feed }) => {
- +