Merge branch 'fe/feat/infinit-query' into 'fe/develop'

Feat: 리뷰 인피니트 쿼리

See merge request s11-s-project/S11P21S002!186
This commit is contained in:
홍창기 2024-09-26 09:07:39 +09:00
commit f53f849641
10 changed files with 257 additions and 63 deletions

View File

@ -13,7 +13,7 @@ export default function AdminLayout() {
<AdminProjectSidebar />
<ResizablePanel className="flex w-full items-center">
<main className="h-full grow">
<main className="h-full grow overflow-y-auto">
<Outlet />
</main>
</ResizablePanel>

View File

@ -0,0 +1,46 @@
import { useState } from 'react';
import { Button } from '../ui/button';
import useAuthStore from '@/stores/useAuthStore';
import useLogoutQuery from '@/queries/auth/useLogoutQuery';
export default function UserProfileForm({ onClose }: { onClose: () => void }) {
const profile = useAuthStore((state) => state.profile);
const { nickname, profileImage } = profile || { nickname: '', profileImage: '' };
const logoutMutation = useLogoutQuery();
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
const handleLogout = async () => {
setIsLoggingOut(true);
logoutMutation.mutate(undefined, {
onSuccess: () => {
onClose();
},
});
};
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-4">
{profileImage ? (
<img
src={profileImage}
alt={`${nickname}'s profile`}
className="h-16 w-16 rounded-full"
/>
) : (
<div className="h-16 w-16 rounded-full bg-gray-300"></div>
)}
<div className="text-lg font-bold">{nickname || 'Guest'}</div>
</div>
<Button
onClick={handleLogout}
variant="outlinePrimary"
className="mt-4"
disabled={isLoggingOut}
>
{isLoggingOut ? '로그아웃 중...' : '로그아웃'}
</Button>
</div>
);
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import { User } from 'lucide-react';
import UserProfileForm from './UserProfileForm';
export default function UserProfileModal() {
const [isOpen, setIsOpen] = React.useState(false);
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);
return (
<Dialog
open={isOpen}
onOpenChange={setIsOpen}
>
<DialogTrigger asChild>
<button
className="flex items-center justify-center p-2"
onClick={handleOpen}
>
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
</button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader title="프로필" />
<UserProfileForm onClose={handleClose} />
</DialogContent>
</Dialog>
);
}

View File

@ -1,7 +1,8 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Bell, User } from 'lucide-react';
import { Bell } from 'lucide-react';
import { useLocation, Link, useParams } from 'react-router-dom';
import UserProfileModal from './UserProfileModal';
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
@ -60,7 +61,7 @@ export default function Header({ className, ...props }: HeaderProps) {
{!isHomePage && (
<div className="flex items-center gap-4 md:gap-5">
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-5" />
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
<UserProfileModal />
</div>
)}
</header>

View File

@ -125,19 +125,22 @@ export const reviewHandlers = [
const projectId = Array.isArray(params.projectId)
? parseInt(params.projectId[0], 10)
: parseInt(params.projectId as string, 10);
console.log(projectId);
const reviewStatus = Array.isArray(params.reviewStatus) ? params.reviewStatus[0] : params.reviewStatus;
const lastReviewId = Array.isArray(params.lastReviewId) ? params.lastReviewId[0] : params.lastReviewId;
const lastReviewId = Array.isArray(params.lastReviewId)
? parseInt(params.lastReviewId[0], 10)
: parseInt(params.lastReviewId as string, 10) || 0;
const limitPage = Array.isArray(params.limitPage)
? parseInt(params.limitPage[0], 10)
: parseInt(params.limitPage as string, 10) || 10;
const reviews: ReviewResponse[] = Array.from({ length: limitPage }, (_, index) => ({
// 총 100개의 리뷰를 생성
const totalReviews = 100;
const reviews: ReviewResponse[] = Array.from({ length: totalReviews }, (_, index) => ({
projectId,
reviewId: lastReviewId ? parseInt(lastReviewId, 10) + index : index + 1,
reviewId: index + 1,
title: `Review ${index + 1}`,
content: `Review content ${index + 1}`,
status: (reviewStatus || 'REQUESTED') as 'REQUESTED' | 'APPROVED' | 'REJECTED',
@ -146,6 +149,10 @@ export const reviewHandlers = [
author: { id: 1, nickname: 'Author', profileImage: '', email: 'author@example.com' },
}));
return HttpResponse.json(reviews);
// 마지막 리뷰 ID 기준으로 데이터를 잘라서 반환
const startIndex = lastReviewId > 0 ? lastReviewId : 0;
const slicedReviews = reviews.slice(startIndex, startIndex + limitPage);
return HttpResponse.json(slicedReviews);
}),
];

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { Suspense, useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery';
import useAuthStore from '@/stores/useAuthStore';
@ -14,33 +14,70 @@ export default function ProjectReviewList() {
const [, setSearchQuery] = useState('');
const [sortValue, setSortValue] = useState('latest');
const { data: projectReviews = [] } = useReviewByStatusQuery(
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useReviewByStatusQuery(
Number(projectId),
memberId,
activeTab !== 'all' ? activeTab : undefined
);
return (
<div>
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
<h1 className="text-xl font-semibold"> </h1>
<Link
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>
const projectReviews = data?.pages.flat() || [];
<ReviewList
reviews={projectReviews}
activeTab={activeTab}
setActiveTab={setActiveTab}
setSearchQuery={setSearchQuery}
sortValue={sortValue}
setSortValue={setSortValue}
workspaceId={Number(workspaceId)}
/>
</div>
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 1.0 }
);
const currentLoadMoreRef = loadMoreRef.current;
if (currentLoadMoreRef) {
observer.observe(currentLoadMoreRef);
}
return () => {
if (currentLoadMoreRef) {
observer.unobserve(currentLoadMoreRef);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<Suspense fallback={<div></div>}>
<div>
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-white px-4">
<h1 className="text-xl font-semibold"> </h1>
<Link
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>
<ReviewList
reviews={projectReviews}
activeTab={activeTab}
setActiveTab={setActiveTab}
setSearchQuery={setSearchQuery}
sortValue={sortValue}
setSortValue={setSortValue}
workspaceId={Number(workspaceId)}
/>
{isFetchingNextPage}
<div
ref={loadMoreRef}
className="h-1"
/>
</div>
</Suspense>
);
}

View File

@ -1,10 +1,10 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import useWorkspaceReviewsQuery from '@/queries/workspaces/useWorkspaceReviewsQuery';
import useAuthStore from '@/stores/useAuthStore';
import ReviewList from '@/components/ReviewList';
import { Button } from '@/components/ui/button';
import { Suspense } from 'react';
export default function WorkspaceReviewList() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const profile = useAuthStore((state) => state.profile);
@ -14,32 +14,70 @@ export default function WorkspaceReviewList() {
const [, setSearchQuery] = useState('');
const [sortValue, setSortValue] = useState('latest');
const { data: workspaceReviews = [] } = useWorkspaceReviewsQuery(
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useWorkspaceReviewsQuery(
Number(workspaceId),
memberId,
activeTab !== 'all' ? activeTab : undefined
);
const workspaceReviews = data?.pages.flat() || [];
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 1.0 }
);
const currentLoadMoreRef = loadMoreRef.current;
if (currentLoadMoreRef) {
observer.observe(currentLoadMoreRef);
}
return () => {
if (currentLoadMoreRef) {
observer.unobserve(currentLoadMoreRef);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
<h1 className="text-xl font-semibold"> </h1>
<Link
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>
<ReviewList
reviews={workspaceReviews}
activeTab={activeTab}
setActiveTab={setActiveTab}
setSearchQuery={setSearchQuery}
sortValue={sortValue}
setSortValue={setSortValue}
workspaceId={Number(workspaceId)}
/>
</div>
<Suspense fallback={<div></div>}>
<div>
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-white px-4">
<h1 className="text-xl font-semibold"> </h1>
<Link
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>
<ReviewList
reviews={workspaceReviews}
activeTab={activeTab}
setActiveTab={setActiveTab}
setSearchQuery={setSearchQuery}
sortValue={sortValue}
setSortValue={setSortValue}
workspaceId={Number(workspaceId)}
/>
{isFetchingNextPage}
<div
ref={loadMoreRef}
className="h-1"
/>
</div>
</Suspense>
);
}

View File

@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useAuthStore from '@/stores/useAuthStore';
import { logout } from '@/api/authApi';
export default function useLogoutQuery() {
const queryClient = useQueryClient();
const { clearAuth } = useAuthStore();
return useMutation({
mutationFn: logout,
onSuccess: () => {
clearAuth();
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
}

View File

@ -1,13 +1,22 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { getReviewByStatus } from '@/api/reviewApi';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ReviewResponse } from '@/types';
export default function useReviewByStatusQuery(
projectId: number,
memberId: number,
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined
) {
return useSuspenseQuery({
return useSuspenseInfiniteQuery<ReviewResponse[]>({
queryKey: ['reviewByStatus', projectId, reviewStatus],
queryFn: () => getReviewByStatus(projectId, memberId, reviewStatus),
queryFn: ({ pageParam = undefined }) => {
return getReviewByStatus(projectId, memberId, reviewStatus, pageParam as number | undefined);
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
const lastReview = lastPage[lastPage.length - 1];
return lastReview.reviewId;
},
initialPageParam: undefined,
});
}

View File

@ -1,15 +1,24 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { getWorkspaceReviews } from '@/api/workspaceApi';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ReviewResponse } from '@/types';
export default function useWorkspaceReviewsQuery(
workspaceId: number,
memberId: number,
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
lastReviewId?: number,
limitPage?: number
limitPage: number = 10
) {
return useSuspenseQuery({
queryKey: ['workspaceReviews', workspaceId, reviewStatus, lastReviewId],
queryFn: () => getWorkspaceReviews(workspaceId, memberId, reviewStatus, lastReviewId, limitPage),
return useSuspenseInfiniteQuery<ReviewResponse[]>({
queryKey: ['workspaceReviews', workspaceId, reviewStatus],
queryFn: ({ pageParam = undefined }) =>
getWorkspaceReviews(workspaceId, memberId, reviewStatus, pageParam as number | undefined, limitPage),
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
const lastReview = lastPage[lastPage.length - 1];
return lastReview.reviewId;
},
initialPageParam: undefined,
});
}