Skip to content
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

Merged
merged 11 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/app/(main)/mypage/_component/MyGatheringList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ import Card from '@/app/components/Card/Card';
import InfiniteScroll from '@/app/components/InfiniteScroll/InfiniteScroll';
import ReviewModal from '@/app/components/Modal/ReviewModal';
import { useState } from 'react';
import { UserJoinedGatheringsData } from '@/types/data.type';
import useParticipation from '@/hooks/useParticipation';
import { UserData } from '@/types/client.type';

const MyGatheringList = () => {
interface MyGatheringListProps {
user: UserData | null;
initData: UserJoinedGatheringsData[];
}

const MyGatheringList = ({ initData, user }: MyGatheringListProps) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [cardId, setCardId] = useState<number>(0);
const { handleWithdrawClickWithId } = useParticipation(user);

const handleOpenModal = (id: number) => {
setCardId(id);
Expand All @@ -22,21 +31,20 @@ const MyGatheringList = () => {
return (
<>
<InfiniteScroll
initData={initData}
queryKey={['myGatherings']}
queryFn={getMyGathergins}
emptyText='아직 참여한 모임이 없습니다.'
errorText='모임을 불러오지 못했습니다.'
renderItem={(item, index) => (
<Card
handleSaveDiscard={() => console.log('Save Discard')}
data={item}
>
<Card data={item}>
<Card.Chips />
<Card.Info />
<Card.Button
handleButtonClick={() => {
item.isCompleted
? handleOpenModal(item.id)
: console.log('Cancel gathering');
: handleWithdrawClickWithId(item.id, ['myGatherings']);
}}
/>
</Card>
Expand Down
13 changes: 13 additions & 0 deletions src/app/(main)/mypage/loading.tsx
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;
8 changes: 6 additions & 2 deletions src/app/(main)/mypage/page.tsx
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} />;
Comment on lines +11 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

};

export default Mygatherings;
34 changes: 34 additions & 0 deletions src/app/api/actions/gatherings/getMyGatherings.ts
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;
6 changes: 2 additions & 4 deletions src/app/api/gatherings/joined/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ export async function GET(req: Request) {

const data = await response.json();

const paginatedData = data.slice(offset, offset + limit);

// 전체 데이터 길이와 비교하여 다음 페이지가 있는지 결정
const hasNextPage = paginatedData.length === limit;
// 데이터 길이와 비교하여 다음 페이지가 있는지 결정
const hasNextPage = data.length === limit;

return NextResponse.json({
data: data, // API에서 반환하는 데이터
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/gatherings/service/getMyGathergins.ts
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

P4) 개인적인 의견인데, 조금 더 구체적으로 네이밍을 하면 더 직관적인 것 같긴 합니다..!

ex)

  • DEFAULT_GATHERINGS_OFFSET
  • DEFAULT_GATHERINGS_LIMIT
  • ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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}`,
Expand Down
20 changes: 17 additions & 3 deletions src/app/components/BottomFloatingBar/BottomFloatingBar.test.tsx
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';

// 유저데이터 모킹
Expand All @@ -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>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4) 궁금해서 여쭤봅니다..!

혹시 {ui} 라고 이름 지으신 이유가 있으실까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

큰 의미는 없습니다 사용하려는 테스트 컴포넌트에 기능 없이 ui만 구현해놔서 직관적으로 ui라고 지었습니다!

options,
);

Comment on lines +29 to +38
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

myGatherings의 쿼리키관리를 위해 useParticipation함수에 queryClient를 추가했더니 테스트 오류가 났습니다.
useQueryClient를 사용하려면 QueryClientProvider로 감싸줘야 하는데, 현재 테스트에서 이를 설정하지 않아서 발생한 문제였습니다.
그래서 테스트코드에 QueryClientProvider를 추가하였습니다.

Comment on lines +33 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

describe('BottomFloatingBar 컴포넌트 테스트', () => {
const mockRouter = { push: jest.fn() };

Expand All @@ -33,7 +45,8 @@ describe('BottomFloatingBar 컴포넌트 테스트', () => {

// 참가자일 때 기본 렌더링 확인
it('renders correctly when the user is a participant', () => {
render(
renderWithQueryClient(
// 헬퍼 함수 사용
<BottomFloatingBar
user={mockUser}
createdBy={12}
Expand Down Expand Up @@ -63,7 +76,8 @@ describe('BottomFloatingBar 컴포넌트 테스트', () => {

// 주최자일 때 기본 렌더링 확인
it('renders correctly when the user is the organizer', () => {
render(
renderWithQueryClient(
// 헬퍼 함수 사용
<BottomFloatingBar
user={mockUser}
createdBy={1}
Expand Down
83 changes: 64 additions & 19 deletions src/app/components/InfiniteScroll/InfiniteScroll.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
}
Expand All @@ -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 } =
Expand All @@ -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],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useInfiniteQuery 훅에서 서버사이드로 받아온 초기데이터를 initialData로 적용했습니다.
처음 받아온 데이터의 길이가 DEFAULT_LIMIT과 같다면 다음 데이터를 받아올 수 있게 됩니다.

},
});

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}
Expand All @@ -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>
)}
Expand Down
1 change: 1 addition & 0 deletions src/app/components/Modal/ReviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const ReviewModal = ({ gatheringId, onClose }: ReviewModalProps) => {
name='리뷰 등록'
type='button'
variant={score !== 0 && comment ? 'default' : 'gray'}
disabled={score === 0 || comment.length === 0}
onClick={handleSubmit}
/>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ export default async function RootLayout({
}: Readonly<{
children: ReactNode;
}>) {
const userData = await getUserData();
const user = await getUserData();
const token = await getCookie('token');
return (
<html lang='ko'>
<body className='flex min-h-dvh flex-col bg-var-gray-100 font-pretendard text-var-gray-900 dark:bg-neutral-950 dark:text-neutral-50'>
<Providers>
<Gnb user={userData} token={token} />
<Gnb user={user} token={token} />
<div className='grow pt-60'>{children}</div>
<div id='modal-root'></div>
<Toaster toastOptions={toastOptions} />
Expand Down
2 changes: 1 addition & 1 deletion src/constants/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const MIN_PARTICIPANTS = 5;

// 무한스크롤 관련 데이터 패칭 상수
export const DEFAULT_OFFSET = 0;
export const DEFAULT_LIMIT = 5;
export const DEFAULT_LIMIT = 10;

export const LIMIT_PER_REQUEST = 10;

Expand Down
Loading
Loading