Skip to content

Commit

Permalink
✨ Follow 기능 구현 (#95)
Browse files Browse the repository at this point in the history
* feat: useFollow unit test 작성

* feat: useUnfollow unit test 작성

* feat: follow axios 함수 구현

* feat: follow query key 추가

* feat: updateRelationshipStatus 구현

* feat: useFollow 구현

* feat: useFollow의 isPrivate -> locked로 수정

* feat: useUnfollow 구현

* feat: updateRelationshipStatus isPrivate -> locked 수정

* feat: follow 요청이 relationshipStatus를 반환하도록 수정

* feat: follow 관련 unit test 수정

테스트 값을 유효한 값으로 변경하고, 구조분해할당을 수정해 적절한 값으로 expect를 진행하도록 했습니다.
또한 유효하지 않은 test를 제거하고, 문구를 더 명확하게 했습니다.

* feat: fetchRelationshipStatus 구현

* feat: useGetRelationshipStatus 구현

* feat: ProfileFollowButton 구현

* feat: SkeletonProfileButton 구현

* feat: SkeletonProfileUser 수정

* feat: ProfileUser에 ProfileFollowButton 추가

* feat: fetchUser 함수 분리

* feat: IPhoneLayout defaultSize 확대

* fix: useFollow, useUnfollow 수정

Querykey로 userId 추가, getQueryData 인터페이스 수정

* fix: updateRelationshipStatus 수정

updateRelationshipStatus가 올바른 data 형식을 return 하도록 수정했습니다.

* feat: useFollow, useUnfollow mutationKey 추가

* feat: handleMutationError에 mutationKeys 배열 생성, follow 추가

* fix: relationshipStatus mock data 수정

* feat: ProfileFollowButton switch문 수정

* feat: followHandler에 followerCount/folloingCount 로직 추가

* feat: useFollow, useUnfollow에서 user 가져오도록 수정

* feat: mutitonKeys -> mutationCautionToastKeys 이름 변경

* feat: useFollow와 useUnfollow hook 병합

* feat: hook 병합에 따라 ProfileFollowButton 수정

* feat: RelationshipStatusCalculate 주석 작성

# Closes #PW-302, #PW-339
  • Loading branch information
Legitgoons authored Jul 2, 2024
1 parent 915f9ed commit 07d8688
Show file tree
Hide file tree
Showing 26 changed files with 404 additions and 33 deletions.
2 changes: 1 addition & 1 deletion src/app/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import './RootLayout.scss';
*/
export const RootLayout = () => {
return (
<IPhoneLayout>
<IPhoneLayout defaultSize={85}>
<div className='wrap'>
<Outlet />
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/mocks/consts/relationshipStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const relationshipStatus: Relationship = {
3: 'pending',
4: 'none',
5: 'following',
6: 'pending',
6: 'none',
7: 'following',
8: 'none',
9: 'none',
Expand Down
20 changes: 11 additions & 9 deletions src/app/mocks/handler/follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,20 @@ export const followHandler = [
case 'none':
if (users[formattedUserId].locked) {
relationshipStatus[formattedUserId] = 'pending';
} else relationshipStatus[formattedUserId] = 'following';
break;
return createHttpSuccessResponse({ relationshipStatus: 'pending' });
} else {
users[formattedUserId].followerCount += 1;
users[1].followingCount += 1;
relationshipStatus[formattedUserId] = 'following';
return createHttpSuccessResponse({ relationshipStatus: 'following' });
}
case 'following':
return createHttpErrorResponse('4220');
case 'pending':
return createHttpErrorResponse('4220');
default:
return createHttpErrorResponse('4040');
}

return createHttpSuccessResponse({});
}),
// 2️⃣ 언팔로우 & 팔로우 요청 취소
http.delete('/users/:user_id/follow', ({ params }) => {
Expand All @@ -56,16 +59,15 @@ export const followHandler = [
return createHttpErrorResponse('4220');
case 'following':
relationshipStatus[formattedUserId] = 'none';
break;
users[formattedUserId].followerCount -= 1;
users[1].followingCount -= 1;
return createHttpSuccessResponse({ relationshipStatus: 'none' });
case 'pending':
relationshipStatus[formattedUserId] = 'none';
break;

return createHttpSuccessResponse({ relationshipStatus: 'none' });
default:
return createHttpErrorResponse('4040');
}

return createHttpSuccessResponse({});
}),
// 3️⃣ 팔로우 확인
http.get('/users/:user_id/follow', ({ params }) => {
Expand Down
1 change: 1 addition & 0 deletions src/features/follow/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFollow } from './useFollow';
74 changes: 74 additions & 0 deletions src/features/follow/api/useFollow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { requestFollow, requestUnfollow } from '@/shared/axios';
import { FetchRelationshipStatus } from '@/shared/consts';
import { QUERY_KEYS } from '@/shared/react-query';
import { isErrorResponse } from '@/shared/utils';

import { updateRelationshipStatus } from '../lib';

export const useFollow = (
userId: number,
locked: boolean,
isFollow: boolean,
) => {
const queryClient = useQueryClient();

const {
data,
mutate: handleFollow,
isPending: isPendingFollow,
} = useMutation({
mutationKey: [QUERY_KEYS.follow],
mutationFn: () =>
isFollow ? requestFollow(userId) : requestUnfollow(userId),
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: [QUERY_KEYS.follow, userId],
});

const previousQueryData =
queryClient.getQueryData<FetchRelationshipStatus>([
QUERY_KEYS.follow,
userId,
]);

if (!previousQueryData) return;

const updatedQueryData = updateRelationshipStatus(
previousQueryData,
locked,
);

await queryClient.setQueryData(
[QUERY_KEYS.follow, userId],
updatedQueryData,
);

return { previousQueryData };
},
onError: (_, __, context) => {
queryClient.setQueryData(
[QUERY_KEYS.follow, userId],
context?.previousQueryData,
);
},
onSuccess: (response, _, context) => {
if (isErrorResponse(response)) {
queryClient.setQueryData(
[QUERY_KEYS.follow, userId],
context.previousQueryData,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.follow, userId] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.users, userId],
});
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.users, 1] });
},
});

return { data, handleFollow, isPendingFollow };
};
1 change: 1 addition & 0 deletions src/features/follow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFollow } from './api';
1 change: 1 addition & 0 deletions src/features/follow/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { updateRelationshipStatus } from './updateRelationshipStatus';
40 changes: 40 additions & 0 deletions src/features/follow/lib/updateRelationshipStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { RelationshipStatus, FetchRelationshipStatus } from '@/shared/consts';

/**
* 팔로우, 언팔로우/팔로우 취소에 따라 변경된 관계 상태를 계산하는 함수
* @param relationshipStatus 이전 관계 상태
* @param locked 비공개 계정 여부
*/

const RelationshipStatusCalculate = (
relationshipStatus: RelationshipStatus,
locked: boolean,
) => {
switch (relationshipStatus) {
case 'self':
return 'self';
case 'following':
return 'none';
case 'none':
return locked ? 'pending' : 'following';
case 'pending':
return 'none';
default:
return relationshipStatus;
}
};

export function updateRelationshipStatus(
previousRelationshipStatusData: FetchRelationshipStatus,
locked: boolean,
) {
return {
code: previousRelationshipStatusData.code,
data: {
relationshipStatus: RelationshipStatusCalculate(
previousRelationshipStatusData.data.relationshipStatus,
locked,
),
},
};
}
85 changes: 85 additions & 0 deletions src/features/follow/test/useFollow.unit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { createQueryClientWrapper } from '@/shared/tests';

import { useFollow } from '../api';

describe('Follow 기능 테스트', () => {
describe('none 상태일 때', () => {
it('공개 계정이라면 following 상태로 변경된다.', async () => {
//given
const { result } = renderHook(() => useFollow(9, false, true), {
wrapper: createQueryClientWrapper(),
});

//when
act(() => result.current.handleFollow());

//then
await waitFor(() => {
const {
data: { relationshipStatus: initialStatus },
} = result.current.data;

expect(initialStatus).toBe('following');
});
});

it('비공개 계정이라면 pending 상태로 변경된다.', async () => {
const { result } = renderHook(() => useFollow(4, true, true), {
wrapper: createQueryClientWrapper(),
});

act(() => result.current.handleFollow());

await waitFor(() => {
const {
data: { relationshipStatus: initialStatus },
} = result.current.data;

expect(initialStatus).toBe('pending');
});
});
});
});

describe('Unfollow 기능 테스트', () => {
it('following 상태일 때, none 상태로 변경된다.', async () => {
// given
const { result } = renderHook(() => useFollow(2, false, false), {
wrapper: createQueryClientWrapper(),
});

// when
act(() => result.current.handleFollow());

// then
await waitFor(() => {
const {
data: { relationshipStatus: initialStatus },
} = result.current.data;

expect(initialStatus).toBe('none');
});
});

it('pending 상태일 때, none 상태로 변경된다.', async () => {
// given
const { result } = renderHook(() => useFollow(3, true, false), {
wrapper: createQueryClientWrapper(),
});

//when
act(() => result.current.handleFollow());

//then
await waitFor(() => {
const {
data: { relationshipStatus: initialStatus },
} = result.current.data;

expect(initialStatus).toBe('none');
});
});
});
34 changes: 34 additions & 0 deletions src/shared/axios/follow/follow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { axiosInstance } from '../config/instance';

/**
* 팔로우 API
* @param userId 유저 아이디
* @returns 관계 상태
*/
export async function requestFollow(userId: number) {
const { data } = await axiosInstance.post(`/users/${userId}/follow`);

return data;
}

/**
* 언팔로우/팔로우 취소 API
* @param userId 유저 아이디
* @returns 관계 상태
*/
export async function requestUnfollow(userId: number) {
const { data } = await axiosInstance.delete(`/users/${userId}/follow`);

return data;
}

/**
* 팔로우 확인 API
* @param userId 유저 아이디
* @returns 관계 상태
*/
export async function fetchRelationshipStatus(userId: number) {
const { data } = await axiosInstance.get(`/users/${userId}/follow`);

return data;
}
1 change: 1 addition & 0 deletions src/shared/axios/follow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './follow';
2 changes: 2 additions & 0 deletions src/shared/axios/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { axiosInstance } from './config';
export * from './like';
export * from './bookmark';
export * from './follow';
export * from './user';
1 change: 1 addition & 0 deletions src/shared/axios/user/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './user';
14 changes: 14 additions & 0 deletions src/shared/axios/user/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { FetchUser } from '@/shared/consts';

import { axiosInstance } from '../config/instance';

/**
* 유저 정보 API
* @param userId 유저 아이디
* @returns 유저 정보
*/
export async function fetchUser(userId: number): Promise<FetchUser> {
const { data } = await axiosInstance.get(`/users/${userId}`);

return data;
}
1 change: 1 addition & 0 deletions src/shared/react-query/consts/keys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const QUERY_KEYS = Object.freeze({
feeds: 'feeds',
users: 'users',
follow: 'follow',
});
5 changes: 4 additions & 1 deletion src/shared/react-query/dir/handleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export function handleMutationError(
mutation: Mutation<unknown, unknown, unknown, unknown>,
) {
const { options } = mutation;
const mutationCautionToastKeys = ['feed-report', 'follow'];

if (options.mutationKey?.includes('feed-report'))
if (
mutationCautionToastKeys.some((key) => options.mutationKey?.includes(key))
)
showToastHandler('caution', '다시 시도해 주세요');
}
1 change: 1 addition & 0 deletions src/widgets/profile-user/api/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useGetUser } from './useGetUser';
export { useGetRelationshipStatus } from './useGetRelationshipStatus';
23 changes: 23 additions & 0 deletions src/widgets/profile-user/api/useGetRelationshipStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';

import { fetchRelationshipStatus } from '@/shared/axios';
import { QUERY_KEYS } from '@/shared/react-query';

export const useGetRelationshipStatus = (userId: number) => {
const {
data: relationshipStatusData,
isLoading: relationshipLoading,
isError: relationshipError,
refetch: refetchRelationshipStatus,
} = useQuery({
queryKey: [QUERY_KEYS.follow, userId],
queryFn: () => fetchRelationshipStatus(userId),
});

return {
relationshipStatusData,
relationshipLoading,
relationshipError,
refetchRelationshipStatus,
};
};
8 changes: 1 addition & 7 deletions src/widgets/profile-user/api/useGetUser.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { useQuery } from '@tanstack/react-query';

import { axiosInstance } from '@/shared/axios';
import { FetchUser } from '@/shared/consts';
import { fetchUser } from '@/shared/axios';
import { QUERY_KEYS } from '@/shared/react-query';

async function fetchUser(userId: number): Promise<FetchUser> {
const { data } = await axiosInstance.get(`/users/${userId}`);
return data;
}

export const useGetUser = (userId: number) => {
const {
data,
Expand Down
Loading

0 comments on commit 07d8688

Please sign in to comment.