diff --git a/src/app/layout/RootLayout.tsx b/src/app/layout/RootLayout.tsx index 8490b3d..088f71f 100644 --- a/src/app/layout/RootLayout.tsx +++ b/src/app/layout/RootLayout.tsx @@ -11,7 +11,7 @@ import './RootLayout.scss'; */ export const RootLayout = () => { return ( - +
diff --git a/src/app/mocks/consts/relationshipStatus.ts b/src/app/mocks/consts/relationshipStatus.ts index 0a0751c..58f1788 100644 --- a/src/app/mocks/consts/relationshipStatus.ts +++ b/src/app/mocks/consts/relationshipStatus.ts @@ -11,7 +11,7 @@ export const relationshipStatus: Relationship = { 3: 'pending', 4: 'none', 5: 'following', - 6: 'pending', + 6: 'none', 7: 'following', 8: 'none', 9: 'none', diff --git a/src/app/mocks/handler/follow.ts b/src/app/mocks/handler/follow.ts index 5bed3db..3be4935 100644 --- a/src/app/mocks/handler/follow.ts +++ b/src/app/mocks/handler/follow.ts @@ -26,8 +26,13 @@ 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': @@ -35,8 +40,6 @@ export const followHandler = [ default: return createHttpErrorResponse('4040'); } - - return createHttpSuccessResponse({}); }), // 2️⃣ 언팔로우 & 팔로우 요청 취소 http.delete('/users/:user_id/follow', ({ params }) => { @@ -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 }) => { diff --git a/src/features/follow/api/index.ts b/src/features/follow/api/index.ts new file mode 100644 index 0000000..dc4600b --- /dev/null +++ b/src/features/follow/api/index.ts @@ -0,0 +1 @@ +export { useFollow } from './useFollow'; diff --git a/src/features/follow/api/useFollow.tsx b/src/features/follow/api/useFollow.tsx new file mode 100644 index 0000000..0924a74 --- /dev/null +++ b/src/features/follow/api/useFollow.tsx @@ -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([ + 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 }; +}; diff --git a/src/features/follow/index.ts b/src/features/follow/index.ts new file mode 100644 index 0000000..ff55e9e --- /dev/null +++ b/src/features/follow/index.ts @@ -0,0 +1 @@ +export { useFollow } from './api'; diff --git a/src/features/follow/lib/index.ts b/src/features/follow/lib/index.ts new file mode 100644 index 0000000..1b7847e --- /dev/null +++ b/src/features/follow/lib/index.ts @@ -0,0 +1 @@ +export { updateRelationshipStatus } from './updateRelationshipStatus'; diff --git a/src/features/follow/lib/updateRelationshipStatus.ts b/src/features/follow/lib/updateRelationshipStatus.ts new file mode 100644 index 0000000..769215b --- /dev/null +++ b/src/features/follow/lib/updateRelationshipStatus.ts @@ -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, + ), + }, + }; +} diff --git a/src/features/follow/test/useFollow.unit.test.tsx b/src/features/follow/test/useFollow.unit.test.tsx new file mode 100644 index 0000000..07f9bca --- /dev/null +++ b/src/features/follow/test/useFollow.unit.test.tsx @@ -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'); + }); + }); +}); diff --git a/src/shared/axios/follow/follow.ts b/src/shared/axios/follow/follow.ts new file mode 100644 index 0000000..294d182 --- /dev/null +++ b/src/shared/axios/follow/follow.ts @@ -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; +} diff --git a/src/shared/axios/follow/index.ts b/src/shared/axios/follow/index.ts new file mode 100644 index 0000000..8e22221 --- /dev/null +++ b/src/shared/axios/follow/index.ts @@ -0,0 +1 @@ +export * from './follow'; diff --git a/src/shared/axios/index.ts b/src/shared/axios/index.ts index 63b64b0..b3b7102 100644 --- a/src/shared/axios/index.ts +++ b/src/shared/axios/index.ts @@ -1,3 +1,5 @@ export { axiosInstance } from './config'; export * from './like'; export * from './bookmark'; +export * from './follow'; +export * from './user'; diff --git a/src/shared/axios/user/index.ts b/src/shared/axios/user/index.ts new file mode 100644 index 0000000..e5abc85 --- /dev/null +++ b/src/shared/axios/user/index.ts @@ -0,0 +1 @@ +export * from './user'; diff --git a/src/shared/axios/user/user.ts b/src/shared/axios/user/user.ts new file mode 100644 index 0000000..c465546 --- /dev/null +++ b/src/shared/axios/user/user.ts @@ -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 { + const { data } = await axiosInstance.get(`/users/${userId}`); + + return data; +} diff --git a/src/shared/react-query/consts/keys.ts b/src/shared/react-query/consts/keys.ts index ea3fc87..46578d5 100644 --- a/src/shared/react-query/consts/keys.ts +++ b/src/shared/react-query/consts/keys.ts @@ -1,4 +1,5 @@ export const QUERY_KEYS = Object.freeze({ feeds: 'feeds', users: 'users', + follow: 'follow', }); diff --git a/src/shared/react-query/dir/handleQuery.ts b/src/shared/react-query/dir/handleQuery.ts index f4b021c..71f46de 100644 --- a/src/shared/react-query/dir/handleQuery.ts +++ b/src/shared/react-query/dir/handleQuery.ts @@ -40,7 +40,10 @@ export function handleMutationError( mutation: Mutation, ) { const { options } = mutation; + const mutationCautionToastKeys = ['feed-report', 'follow']; - if (options.mutationKey?.includes('feed-report')) + if ( + mutationCautionToastKeys.some((key) => options.mutationKey?.includes(key)) + ) showToastHandler('caution', '다시 시도해 주세요'); } diff --git a/src/widgets/profile-user/api/index.ts b/src/widgets/profile-user/api/index.ts index d184899..b66b09c 100644 --- a/src/widgets/profile-user/api/index.ts +++ b/src/widgets/profile-user/api/index.ts @@ -1 +1,2 @@ export { useGetUser } from './useGetUser'; +export { useGetRelationshipStatus } from './useGetRelationshipStatus'; diff --git a/src/widgets/profile-user/api/useGetRelationshipStatus.ts b/src/widgets/profile-user/api/useGetRelationshipStatus.ts new file mode 100644 index 0000000..aedc6f7 --- /dev/null +++ b/src/widgets/profile-user/api/useGetRelationshipStatus.ts @@ -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, + }; +}; diff --git a/src/widgets/profile-user/api/useGetUser.ts b/src/widgets/profile-user/api/useGetUser.ts index 825e1ef..1d85992 100644 --- a/src/widgets/profile-user/api/useGetUser.ts +++ b/src/widgets/profile-user/api/useGetUser.ts @@ -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 { - const { data } = await axiosInstance.get(`/users/${userId}`); - return data; -} - export const useGetUser = (userId: number) => { const { data, diff --git a/src/widgets/profile-user/ui/ProfileFollowButton.scss b/src/widgets/profile-user/ui/ProfileFollowButton.scss new file mode 100644 index 0000000..ff9b525 --- /dev/null +++ b/src/widgets/profile-user/ui/ProfileFollowButton.scss @@ -0,0 +1,25 @@ +@mixin profileFollowButton() { + width: 46px; + height: 26px; + border-radius: 5px; +} + +.profile-follow-btn { + @include profileFollowButton(); + color: white; + background-color: $mint3; + + &:disabled { + color: white; + } +} + +.profile-unfollow-btn { + @include profileFollowButton(); + color: $gray4; + background-color: $gray1; + + &:disabled { + color: $gray4; + } +} diff --git a/src/widgets/profile-user/ui/ProfileFollowButton.tsx b/src/widgets/profile-user/ui/ProfileFollowButton.tsx new file mode 100644 index 0000000..9dfbbb4 --- /dev/null +++ b/src/widgets/profile-user/ui/ProfileFollowButton.tsx @@ -0,0 +1,45 @@ +import './ProfileFollowButton.scss'; + +import { useFollow } from '@/features/follow'; +import { RelationshipStatus } from '@/shared/consts'; + +interface ProfileFollowButtonProps { + relationshipStatus: RelationshipStatus; + userId: number; + locked: boolean; +} + +export const ProfileFollowButton = ({ + relationshipStatus, + userId, + locked, +}: ProfileFollowButtonProps) => { + const isfollow = relationshipStatus === 'none' ? true : false; + const { handleFollow, isPendingFollow } = useFollow(userId, locked, isfollow); + + switch (relationshipStatus) { + case 'none': + return ( + + ); + case 'pending': + case 'following': + return ( + + ); + default: + return null; + } +}; diff --git a/src/widgets/profile-user/ui/ProfileUser.tsx b/src/widgets/profile-user/ui/ProfileUser.tsx index bc9765f..69ca39f 100644 --- a/src/widgets/profile-user/ui/ProfileUser.tsx +++ b/src/widgets/profile-user/ui/ProfileUser.tsx @@ -1,10 +1,12 @@ import { NetworkError, PageHeader } from '@/shared/ui'; -import { useGetUser } from '../api'; +import { useGetUser, useGetRelationshipStatus } from '../api'; import { ProfileCount } from './ProfileCount'; import './ProfileUser.scss'; +import { ProfileFollowButton } from './ProfileFollowButton'; import { ProfileUserImage } from './ProfileUserImage'; +import { SkeletonProfileButton } from './SkeletonProfileButton'; import { SkeletonProfileUser } from './SkeletonProfileUser'; interface ProfileUserProps { @@ -14,6 +16,12 @@ interface ProfileUserProps { export const ProfileUser = ({ userId, isOwner }: ProfileUserProps) => { const { data, isLoading, isError, refetchUser } = useGetUser(userId); + const { + relationshipStatusData, + relationshipLoading, + relationshipError, + refetchRelationshipStatus, + } = useGetRelationshipStatus(userId); if (isLoading) { return ( @@ -27,16 +35,22 @@ export const ProfileUser = ({ userId, isOwner }: ProfileUserProps) => { if (isError || !data) { return ; } + if (relationshipError) { + return ; + } const { profileImage, name, username, + locked, feedCount, followerCount, followingCount, } = data.data.user; + const relationshipStatus = relationshipStatusData?.data.relationshipStatus; + return ( <> @@ -48,10 +62,16 @@ export const ProfileUser = ({ userId, isOwner }: ProfileUserProps) => { isOwner={isOwner} />

{username}

- {isOwner ? ( + {relationshipLoading ? ( + + ) : relationshipStatus === 'self' ? ( ) : ( - + )}
diff --git a/src/widgets/profile-user/ui/SkeletonProfileButton.scss b/src/widgets/profile-user/ui/SkeletonProfileButton.scss new file mode 100644 index 0000000..f2db3f7 --- /dev/null +++ b/src/widgets/profile-user/ui/SkeletonProfileButton.scss @@ -0,0 +1,7 @@ +.skeleton-profile-btn { + @include skeletonAnimation(); + background-color: $gray2; + width: 68px; + height: 26px; + border-radius: 6px; +} diff --git a/src/widgets/profile-user/ui/SkeletonProfileButton.tsx b/src/widgets/profile-user/ui/SkeletonProfileButton.tsx new file mode 100644 index 0000000..76faf76 --- /dev/null +++ b/src/widgets/profile-user/ui/SkeletonProfileButton.tsx @@ -0,0 +1,3 @@ +export const SkeletonProfileButton = () => { + return
; +}; diff --git a/src/widgets/profile-user/ui/SkeletonProfileUser.scss b/src/widgets/profile-user/ui/SkeletonProfileUser.scss index d122c21..8d35b0e 100644 --- a/src/widgets/profile-user/ui/SkeletonProfileUser.scss +++ b/src/widgets/profile-user/ui/SkeletonProfileUser.scss @@ -13,7 +13,7 @@ justify-content: space-between; align-items: center; - .skeleton-proile { + .skeleton-profile { @include skeletonAnimation(); background-color: $gray2; position: relative; @@ -30,14 +30,6 @@ height: 14px; border-radius: 4px; } - - .skeleton-profile-btn { - @include skeletonAnimation(); - background-color: $gray2; - width: 68px; - height: 26px; - border-radius: 6px; - } } .skeleton-profile-count-container { diff --git a/src/widgets/profile-user/ui/SkeletonProfileUser.tsx b/src/widgets/profile-user/ui/SkeletonProfileUser.tsx index 4593c71..e80d0ad 100644 --- a/src/widgets/profile-user/ui/SkeletonProfileUser.tsx +++ b/src/widgets/profile-user/ui/SkeletonProfileUser.tsx @@ -1,12 +1,13 @@ +import { SkeletonProfileButton } from './SkeletonProfileButton'; import './SkeletonProfileUser.scss'; export const SkeletonProfileUser = () => { return (
-
+
-
+