-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor: mypage 리팩토링 #178
Changes from 9 commits
80ee9cb
2610178
6080a19
db314cf
972c600
d1a2e15
a07f4b2
f3d02ec
0b812fe
5101e59
bb7ed06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
'use client'; | ||
|
||
import Loader from '@/app/components/Loader/Loader'; | ||
|
||
const Loading = () => { | ||
return ( | ||
<div className='flex min-h-360 items-center justify-center'> | ||
<Loader />; | ||
</div> | ||
); | ||
}; | ||
|
||
export default Loading; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,17 @@ | ||
import { Metadata } from 'next'; | ||
import MyGatheringList from './_component/MyGatheringList'; | ||
import getMyGatherings from '@/app/api/actions/gatherings/getMyGatherings'; | ||
import { getUserData } from '@/app/api/actions/mypage/getUserData'; | ||
|
||
export const metadata: Metadata = { | ||
title: '나의 모임 | Soothe With Me', | ||
description: 'Soothe With Me 나의 모임 페이지입니다.', | ||
}; | ||
|
||
const Mygatherings = () => { | ||
return <MyGatheringList />; | ||
const Mygatherings = async () => { | ||
const myGatherings = await getMyGatherings(); | ||
const user = await getUserData(); | ||
return <MyGatheringList initData={myGatherings} user={user} />; | ||
}; | ||
|
||
export default Mygatherings; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
'use server'; | ||
|
||
import { getCookie } from '@/actions/auth/cookie/cookie'; | ||
import { GatheringType } from '@/types/data.type'; | ||
|
||
const getMyGatherings = async () => { | ||
const token = await getCookie('token'); | ||
try { | ||
const res = await fetch( | ||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/gatherings/joined?limit=10&offset=0`, | ||
{ | ||
method: 'GET', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
Authorization: `Bearer ${token}`, | ||
}, | ||
}, | ||
); | ||
|
||
if (!res.ok) { | ||
throw new Error('모임을 불러오지 못했습니다.'); | ||
} | ||
|
||
const data: GatheringType[] = await res.json(); | ||
|
||
return data; | ||
} catch (error) { | ||
throw new Error( | ||
error instanceof Error ? error.message : '모임을 불러오지 못했습니다.', | ||
); | ||
} | ||
}; | ||
|
||
export default getMyGatherings; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,11 @@ | ||
'use client'; | ||
|
||
import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '@/constants/common'; | ||
import { FetchGatheringsResponse } from '@/types/data.type'; | ||
|
||
const getMyGathergins = async ( | ||
offset = 0, | ||
limit = 5, | ||
offset = DEFAULT_OFFSET, | ||
limit = DEFAULT_LIMIT, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 P4) 개인적인 의견인데, 조금 더 구체적으로 네이밍을 하면 더 직관적인 것 같긴 합니다..! ex)
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저도 더 구체적으로 짜는게 직관적일 것 같아 반영했습니다 감사합니다! |
||
): Promise<FetchGatheringsResponse> => { | ||
const response = await fetch( | ||
`/api/gatherings/joined?offset=${offset}&limit=${limit}`, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,12 @@ | ||
'use client'; | ||
|
||
import { render, screen } from '@testing-library/react'; | ||
import { render, screen, RenderOptions } from '@testing-library/react'; | ||
import { useRouter } from 'next/navigation'; | ||
import BottomFloatingBar from './BottomFloatingBar'; | ||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
import { UserData } from '@/types/client.type'; | ||
import { GatheringParticipantsType } from '@/types/data.type'; | ||
import { ReactNode } from 'react'; | ||
import '@testing-library/jest-dom'; | ||
|
||
// 유저데이터 모킹 | ||
|
@@ -24,6 +26,16 @@ jest.mock('next/navigation', () => ({ | |
useParams: jest.fn(() => ({ id: '1' })), | ||
})); | ||
|
||
// QueryClient 인스턴스 생성 | ||
const queryClient = new QueryClient(); | ||
|
||
// QueryClientProvider로 감싸주는 헬퍼 함수 정의 | ||
const renderWithQueryClient = (ui: ReactNode, options?: RenderOptions) => | ||
render( | ||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P4) 궁금해서 여쭤봅니다..! 혹시 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 큰 의미는 없습니다 사용하려는 테스트 컴포넌트에 기능 없이 ui만 구현해놔서 직관적으로 ui라고 지었습니다! |
||
options, | ||
); | ||
|
||
Comment on lines
+29
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. myGatherings의 쿼리키관리를 위해 useParticipation함수에 queryClient를 추가했더니 테스트 오류가 났습니다.
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
describe('BottomFloatingBar 컴포넌트 테스트', () => { | ||
const mockRouter = { push: jest.fn() }; | ||
|
||
|
@@ -33,7 +45,8 @@ describe('BottomFloatingBar 컴포넌트 테스트', () => { | |
|
||
// 참가자일 때 기본 렌더링 확인 | ||
it('renders correctly when the user is a participant', () => { | ||
render( | ||
renderWithQueryClient( | ||
// 헬퍼 함수 사용 | ||
<BottomFloatingBar | ||
user={mockUser} | ||
createdBy={12} | ||
|
@@ -63,7 +76,8 @@ describe('BottomFloatingBar 컴포넌트 테스트', () => { | |
|
||
// 주최자일 때 기본 렌더링 확인 | ||
it('renders correctly when the user is the organizer', () => { | ||
render( | ||
renderWithQueryClient( | ||
// 헬퍼 함수 사용 | ||
<BottomFloatingBar | ||
user={mockUser} | ||
createdBy={1} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 호민님께서 만든 그라데이션을 적용했습니다. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,8 @@ | ||
import { useEffect } from 'react'; | ||
import { useEffect, useState } from 'react'; | ||
import { useInfiniteQuery } from '@tanstack/react-query'; | ||
import { useInView } from 'react-intersection-observer'; | ||
import { DEFAULT_LIMIT } from '@/constants/common'; | ||
import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '@/constants/common'; | ||
import Loader from '../Loader/Loader'; | ||
interface ItemWithId { | ||
id: number; | ||
} | ||
|
@@ -13,23 +14,33 @@ interface InfiniteQueryResponse<T extends ItemWithId> { | |
} | ||
|
||
interface InfiniteScrollProps<T extends ItemWithId> { | ||
initData: T[]; | ||
queryKey: string[]; | ||
queryFn: (offset?: number) => Promise<InfiniteQueryResponse<T>>; | ||
limit?: number; | ||
emptyText: string; | ||
errorText: string; | ||
renderItem: (item: T, index: number) => JSX.Element; | ||
} | ||
|
||
const InfiniteScroll = <T extends ItemWithId>({ | ||
initData, | ||
queryKey, | ||
queryFn, | ||
limit = DEFAULT_LIMIT, | ||
emptyText, | ||
errorText, | ||
renderItem, | ||
}: InfiniteScrollProps<T>) => { | ||
const { ref, inView } = useInView({ | ||
triggerOnce: false, | ||
threshold: 1.0, | ||
const [topGradientVisible, setTopGradientVisible] = useState(false); | ||
const [bottomGradientVisible, setBottomGradientVisible] = useState(false); | ||
|
||
const { ref, inView } = useInView({ threshold: 0 }); | ||
const { ref: firstGatheringRef, inView: firstInView } = useInView({ | ||
threshold: 0, | ||
}); | ||
const { ref: lastGatheringRef, inView: lastInView } = useInView({ | ||
threshold: 0, | ||
}); | ||
|
||
const { data, isError, isFetching, fetchNextPage, hasNextPage } = | ||
|
@@ -39,17 +50,29 @@ const InfiniteScroll = <T extends ItemWithId>({ | |
getNextPageParam: (lastPage) => { | ||
return lastPage.hasNextPage ? lastPage.offset + limit : undefined; | ||
}, | ||
initialPageParam: 0, | ||
initialPageParam: DEFAULT_OFFSET, | ||
initialData: { | ||
pages: [ | ||
{ | ||
hasNextPage: initData.length === DEFAULT_LIMIT, | ||
offset: DEFAULT_OFFSET, | ||
data: initData, | ||
}, | ||
], | ||
pageParams: [DEFAULT_OFFSET], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. useInfiniteQuery 훅에서 서버사이드로 받아온 초기데이터를 initialData로 적용했습니다. |
||
}, | ||
}); | ||
|
||
useEffect(() => { | ||
setTopGradientVisible(!firstInView); | ||
setBottomGradientVisible(!lastInView); | ||
if (inView && hasNextPage) { | ||
fetchNextPage(); | ||
} | ||
}, [inView]); | ||
}, [inView, firstInView, lastInView]); | ||
|
||
// 데이터가 없거나 비어있을 때 emptyText를 표시 | ||
if (!data || data.pages.length === 0) { | ||
if (!data || data.pages[0].data.length === 0) { | ||
return ( | ||
<div className='flex grow items-center justify-center text-[14px] font-medium text-gray-500 dark:text-neutral-200'> | ||
{emptyText} | ||
|
@@ -60,25 +83,47 @@ const InfiniteScroll = <T extends ItemWithId>({ | |
if (isError) | ||
return ( | ||
<div className='flex grow items-center justify-center text-[14px] font-medium text-gray-500 dark:text-neutral-200'> | ||
모임을 불러오지 못했습니다. | ||
{errorText} | ||
</div> | ||
); | ||
|
||
const allItems = data.pages.flatMap((page) => page.data); | ||
return ( | ||
<> | ||
{/* Top gradient */} | ||
<div | ||
className={`fixed left-0 right-0 top-56 z-[30] h-16 bg-gradient-to-b from-white to-transparent p-10 transition-opacity duration-500 ease-in-out md:top-60 ${ | ||
topGradientVisible ? 'opacity-100' : 'opacity-0' | ||
}`} | ||
/> | ||
|
||
{/* Bottom gradient */} | ||
<div | ||
className={`fixed bottom-0 left-0 right-0 z-[30] h-16 bg-gradient-to-t from-white to-transparent p-10 transition-opacity duration-500 ease-in-out ${ | ||
bottomGradientVisible ? 'opacity-100' : 'opacity-0' | ||
}`} | ||
/> | ||
|
||
<ul className='flex h-full flex-col'> | ||
{data && | ||
data.pages.map((page) => | ||
page.data.map((item, index: number) => ( | ||
<li key={item.id}>{renderItem(item, index)}</li> // 사용자 정의 컴포넌트를 렌더링 | ||
)), | ||
)} | ||
{allItems.map((item, index) => ( | ||
<li | ||
key={item.id} | ||
ref={ | ||
index === 0 | ||
? firstGatheringRef | ||
: index === initData.length - 1 | ||
? lastGatheringRef | ||
: null | ||
} | ||
> | ||
{renderItem(item, index)} | ||
</li> | ||
))} | ||
</ul> | ||
{isFetching && ( | ||
<div className='flex grow items-center justify-center'> | ||
<div className='h-6 w-6 animate-spin rounded-full border-4 border-gray-300 border-t-transparent'></div> | ||
<span className='ml-2 text-[14px] font-medium text-gray-500'> | ||
Loading... | ||
<div className='flex h-80 grow items-center justify-center'> | ||
<span> | ||
<Loader /> | ||
</span> | ||
</div> | ||
)} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,9 +6,11 @@ import deleteGatheringToWithdraw from '@/app/api/actions/gatherings/deleteGather | |
import { UserData } from '@/types/client.type'; | ||
|
||
import toast from 'react-hot-toast'; | ||
import { useQueryClient } from '@tanstack/react-query'; | ||
|
||
export default function useParticipation(user: UserData | null) { | ||
const params = useParams(); | ||
const queryClient = useQueryClient(); | ||
|
||
const [hasParticipated, setHasParticipated] = useState<boolean>(false); | ||
const [isShowPopup, setIsShowPopup] = useState<boolean>(false); | ||
|
@@ -48,12 +50,27 @@ export default function useParticipation(user: UserData | null) { | |
} | ||
}; | ||
|
||
const handleWithdrawClickWithId = async (id: number, queryKey: string[]) => { | ||
const { success, message } = await deleteGatheringToWithdraw(id); | ||
|
||
if (!success) { | ||
toast.error(message); | ||
return; | ||
} | ||
// 쿼리 무효화 함수 | ||
await queryClient.invalidateQueries({ queryKey }); // querykey 무효화시켜서 취소한 모임 반영하여 최신화 | ||
Comment on lines
+60
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👏 |
||
|
||
toast.success(message); | ||
setHasParticipated(false); | ||
}; | ||
|
||
Comment on lines
+53
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 모임의 id값을 받아와서 모임취소 요청을 하는 함수입니다. |
||
return { | ||
hasParticipated, | ||
setHasParticipated, | ||
isShowPopup, | ||
setIsShowPopup, | ||
handleJoinClick, | ||
handleWithdrawClick, | ||
handleWithdrawClickWithId, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍