Skip to content

Commit

Permalink
✨ 좋아요 및 좋아요 취소 API (#42)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
BangDori authored May 10, 2024
1 parent e99bbbe commit 261b4ac
Show file tree
Hide file tree
Showing 29 changed files with 274 additions and 39 deletions.
2 changes: 1 addition & 1 deletion public/assets/sprites/common.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/app/mocks/consts/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const errorMessage = Object.freeze({
'0000': 'Unknown error',
'4003': 'Malformed request body',
'4040': 'Requested resource not found',
'4220': 'Unprocessable Content',
Expand Down
26 changes: 16 additions & 10 deletions src/app/mocks/handler/like.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ export const likeHandlers = [
return createHttpErrorResponse('4040');
}

if (!likeInfo.isLiked) {
likeInfo.isLiked = true;
likeInfo.totalCount += 1;
feeds[formattedFeedId].isLiked = true;
feeds[formattedFeedId].likeCount += 1;
// 이미 좋아요 누른 경우
if (likeInfo.isLiked) {
return createHttpErrorResponse('0000');
}

likeInfo.isLiked = true;
likeInfo.totalCount += 1;
feeds[formattedFeedId].isLiked = true;
feeds[formattedFeedId].likeCount += 1;

return createHttpSuccessResponse({ isLiked: true });
}),

Expand All @@ -66,13 +69,16 @@ export const likeHandlers = [
return createHttpErrorResponse('4040');
}

if (likeInfo.isLiked) {
likeInfo.isLiked = false;
likeInfo.totalCount -= 1;
feeds[formattedFeedId].isLiked = false;
feeds[formattedFeedId].likeCount -= 1;
// 이미 좋아요 취소를 누른 경우
if (!likeInfo.isLiked) {
return createHttpErrorResponse('0000');
}

likeInfo.isLiked = false;
likeInfo.totalCount -= 1;
feeds[formattedFeedId].isLiked = false;
feeds[formattedFeedId].likeCount -= 1;

return createHttpSuccessResponse({ isLiked: false });
}),
];
Empty file removed src/features/.gitkeep
Empty file.
1 change: 1 addition & 0 deletions src/features/feed-main-like/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useLikes } from './useLikes';
60 changes: 60 additions & 0 deletions src/features/feed-main-like/api/useLikes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { requestLikeFeed, requestUnlikeFeed } from '@/shared/axios';
import { QUERY_KEYS } from '@/shared/consts';
import { isErrorResponse } from '@/shared/utils';

import { FeedsQueryData } from '../consts';
import { updateLikeStatusInFeeds } from '../lib';

export const useLikes = (feedId: number, isLiked: boolean) => {
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<FeedsQueryData>([
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 };
};
6 changes: 6 additions & 0 deletions src/features/feed-main-like/consts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FetchFeeds } from '@/shared/consts';

export interface FeedsQueryData {
queryParams: number[];
pages: FetchFeeds[];
}
1 change: 1 addition & 0 deletions src/features/feed-main-like/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LikeButton } from './ui';
27 changes: 27 additions & 0 deletions src/features/feed-main-like/lib/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
}),
};
}
43 changes: 43 additions & 0 deletions src/features/feed-main-like/test/useLikes.test.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
28 changes: 28 additions & 0 deletions src/features/feed-main-like/ui/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -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<LikeButtonProps> = ({ feedId, isLiked }) => {
const { handleLikeFeed, isPending } = useLikes(feedId, isLiked);

return (
<button
className='icon icon-btn'
onClick={() => handleLikeFeed()}
disabled={isPending}
>
<Icon
name='like'
width='20'
height='20'
color={isLiked ? ICON_ACTIVE_COLOR : 'none'}
/>
</button>
);
};
1 change: 1 addition & 0 deletions src/features/feed-main-like/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LikeButton } from './LikeButton';
1 change: 1 addition & 0 deletions src/features/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LikeButton } from './feed-main-like';
1 change: 1 addition & 0 deletions src/shared/axios/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { axiosInstance } from './config/instance';
export * from './like';
23 changes: 23 additions & 0 deletions src/shared/axios/like/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/shared/consts/color/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ICON_ACTIVE_COLOR = '#00D5E1';
1 change: 1 addition & 0 deletions src/shared/consts/color/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './color';
1 change: 1 addition & 0 deletions src/shared/consts/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { QUERY_KEYS } from './query-key/queryKeys';
export type * from './types';
export * from './color';
11 changes: 11 additions & 0 deletions src/shared/consts/types/feed.ts
Original file line number Diff line number Diff line change
@@ -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<User, 'id' | 'profileImage' | 'name'>;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/consts/types/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
27 changes: 22 additions & 5 deletions src/shared/tests/setup.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

// 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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={baseEntries}>{children}</MemoryRouter>
</QueryClientProvider>,
);
}
};

// eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';
Expand Down
5 changes: 4 additions & 1 deletion src/shared/ui/icon/ui/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ interface IconProps {
export const Icon: React.FC<IconProps> = ({ name, width, height, color }) => {
return (
<svg width={width} height={height} fill={color || 'none'}>
<use xlinkHref={`assets/sprites/common.svg#${name}-icon`} />
<use
xlinkHref={`assets/sprites/common.svg#${name}-icon`}
color={color || 'none'}
/>
</svg>
);
};
1 change: 1 addition & 0 deletions src/shared/utils/date/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './date';
3 changes: 2 additions & 1 deletion src/shared/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './date/date';
export * from './date';
export * from './response';
1 change: 1 addition & 0 deletions src/shared/utils/response/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './response';
8 changes: 8 additions & 0 deletions src/shared/utils/response/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
interface Response {
code: string;
data: unknown;
}

export function isErrorResponse(response: Response) {
return response.code !== '2000';
}
13 changes: 1 addition & 12 deletions src/widgets/feed-main-list/api/useInfinityFeeds.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading

0 comments on commit 261b4ac

Please sign in to comment.