Merge branch 'fe/feat/infinit-query' into 'fe/develop'
Feat: 리뷰 인피니트 쿼리 See merge request s11-s-project/S11P21S002!186
This commit is contained in:
commit
f53f849641
@ -13,7 +13,7 @@ export default function AdminLayout() {
|
|||||||
<AdminProjectSidebar />
|
<AdminProjectSidebar />
|
||||||
|
|
||||||
<ResizablePanel className="flex w-full items-center">
|
<ResizablePanel className="flex w-full items-center">
|
||||||
<main className="h-full grow">
|
<main className="h-full grow overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
46
frontend/src/components/Header/UserProfileForm.tsx
Normal file
46
frontend/src/components/Header/UserProfileForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
31
frontend/src/components/Header/UserProfileModal.tsx
Normal file
31
frontend/src/components/Header/UserProfileModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { useLocation, Link, useParams } from 'react-router-dom';
|
||||||
|
import UserProfileModal from './UserProfileModal';
|
||||||
|
|
||||||
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
@ -60,7 +61,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
|||||||
{!isHomePage && (
|
{!isHomePage && (
|
||||||
<div className="flex items-center gap-4 md:gap-5">
|
<div className="flex items-center gap-4 md:gap-5">
|
||||||
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
@ -125,19 +125,22 @@ export const reviewHandlers = [
|
|||||||
const projectId = Array.isArray(params.projectId)
|
const projectId = Array.isArray(params.projectId)
|
||||||
? parseInt(params.projectId[0], 10)
|
? parseInt(params.projectId[0], 10)
|
||||||
: parseInt(params.projectId as string, 10);
|
: parseInt(params.projectId as string, 10);
|
||||||
console.log(projectId);
|
|
||||||
|
|
||||||
const reviewStatus = Array.isArray(params.reviewStatus) ? params.reviewStatus[0] : params.reviewStatus;
|
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)
|
const limitPage = Array.isArray(params.limitPage)
|
||||||
? parseInt(params.limitPage[0], 10)
|
? parseInt(params.limitPage[0], 10)
|
||||||
: parseInt(params.limitPage as string, 10) || 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,
|
projectId,
|
||||||
reviewId: lastReviewId ? parseInt(lastReviewId, 10) + index : index + 1,
|
reviewId: index + 1,
|
||||||
title: `Review ${index + 1}`,
|
title: `Review ${index + 1}`,
|
||||||
content: `Review content ${index + 1}`,
|
content: `Review content ${index + 1}`,
|
||||||
status: (reviewStatus || 'REQUESTED') as 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
status: (reviewStatus || 'REQUESTED') as 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||||
@ -146,6 +149,10 @@ export const reviewHandlers = [
|
|||||||
author: { id: 1, nickname: 'Author', profileImage: '', email: 'author@example.com' },
|
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);
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery';
|
import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery';
|
||||||
import useAuthStore from '@/stores/useAuthStore';
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
@ -14,33 +14,70 @@ export default function ProjectReviewList() {
|
|||||||
const [, setSearchQuery] = useState('');
|
const [, setSearchQuery] = useState('');
|
||||||
const [sortValue, setSortValue] = useState('latest');
|
const [sortValue, setSortValue] = useState('latest');
|
||||||
|
|
||||||
const { data: projectReviews = [] } = useReviewByStatusQuery(
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useReviewByStatusQuery(
|
||||||
Number(projectId),
|
Number(projectId),
|
||||||
memberId,
|
memberId,
|
||||||
activeTab !== 'all' ? activeTab : undefined
|
activeTab !== 'all' ? activeTab : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const projectReviews = data?.pages.flat() || [];
|
||||||
<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
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
reviews={projectReviews}
|
|
||||||
activeTab={activeTab}
|
useEffect(() => {
|
||||||
setActiveTab={setActiveTab}
|
if (!hasNextPage || isFetchingNextPage) return;
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
sortValue={sortValue}
|
const observer = new IntersectionObserver(
|
||||||
setSortValue={setSortValue}
|
(entries) => {
|
||||||
workspaceId={Number(workspaceId)}
|
if (entries[0].isIntersecting) {
|
||||||
/>
|
fetchNextPage();
|
||||||
</div>
|
}
|
||||||
|
},
|
||||||
|
{ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import useWorkspaceReviewsQuery from '@/queries/workspaces/useWorkspaceReviewsQuery';
|
import useWorkspaceReviewsQuery from '@/queries/workspaces/useWorkspaceReviewsQuery';
|
||||||
import useAuthStore from '@/stores/useAuthStore';
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
import ReviewList from '@/components/ReviewList';
|
import ReviewList from '@/components/ReviewList';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Suspense } from 'react';
|
||||||
export default function WorkspaceReviewList() {
|
export default function WorkspaceReviewList() {
|
||||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||||
const profile = useAuthStore((state) => state.profile);
|
const profile = useAuthStore((state) => state.profile);
|
||||||
@ -14,32 +14,70 @@ export default function WorkspaceReviewList() {
|
|||||||
const [, setSearchQuery] = useState('');
|
const [, setSearchQuery] = useState('');
|
||||||
const [sortValue, setSortValue] = useState('latest');
|
const [sortValue, setSortValue] = useState('latest');
|
||||||
|
|
||||||
const { data: workspaceReviews = [] } = useWorkspaceReviewsQuery(
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useWorkspaceReviewsQuery(
|
||||||
Number(workspaceId),
|
Number(workspaceId),
|
||||||
memberId,
|
memberId,
|
||||||
activeTab !== 'all' ? activeTab : undefined
|
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 (
|
return (
|
||||||
<div>
|
<Suspense fallback={<div></div>}>
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
|
<div>
|
||||||
<h1 className="text-xl font-semibold">워크스페이스 리뷰</h1>
|
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-white px-4">
|
||||||
<Link
|
<h1 className="text-xl font-semibold">워크스페이스 리뷰</h1>
|
||||||
to={`/admin/${workspaceId}/reviews/request`}
|
<Link
|
||||||
className="ml-auto"
|
to={`/admin/${workspaceId}/reviews/request`}
|
||||||
>
|
className="ml-auto"
|
||||||
<Button variant="outlinePrimary">리뷰 요청</Button>
|
>
|
||||||
</Link>
|
<Button variant="outlinePrimary">리뷰 요청</Button>
|
||||||
</header>
|
</Link>
|
||||||
<ReviewList
|
</header>
|
||||||
reviews={workspaceReviews}
|
|
||||||
activeTab={activeTab}
|
<ReviewList
|
||||||
setActiveTab={setActiveTab}
|
reviews={workspaceReviews}
|
||||||
setSearchQuery={setSearchQuery}
|
activeTab={activeTab}
|
||||||
sortValue={sortValue}
|
setActiveTab={setActiveTab}
|
||||||
setSortValue={setSortValue}
|
setSearchQuery={setSearchQuery}
|
||||||
workspaceId={Number(workspaceId)}
|
sortValue={sortValue}
|
||||||
/>
|
setSortValue={setSortValue}
|
||||||
</div>
|
workspaceId={Number(workspaceId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isFetchingNextPage}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={loadMoreRef}
|
||||||
|
className="h-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
16
frontend/src/queries/auth/useLogoutQuery.ts
Normal file
16
frontend/src/queries/auth/useLogoutQuery.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -1,13 +1,22 @@
|
|||||||
|
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { getReviewByStatus } from '@/api/reviewApi';
|
import { getReviewByStatus } from '@/api/reviewApi';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { ReviewResponse } from '@/types';
|
||||||
|
|
||||||
export default function useReviewByStatusQuery(
|
export default function useReviewByStatusQuery(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
memberId: number,
|
memberId: number,
|
||||||
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined
|
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined
|
||||||
) {
|
) {
|
||||||
return useSuspenseQuery({
|
return useSuspenseInfiniteQuery<ReviewResponse[]>({
|
||||||
queryKey: ['reviewByStatus', projectId, reviewStatus],
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,24 @@
|
|||||||
|
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { getWorkspaceReviews } from '@/api/workspaceApi';
|
import { getWorkspaceReviews } from '@/api/workspaceApi';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { ReviewResponse } from '@/types';
|
||||||
|
|
||||||
export default function useWorkspaceReviewsQuery(
|
export default function useWorkspaceReviewsQuery(
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
memberId: number,
|
memberId: number,
|
||||||
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||||
lastReviewId?: number,
|
limitPage: number = 10
|
||||||
limitPage?: number
|
|
||||||
) {
|
) {
|
||||||
return useSuspenseQuery({
|
return useSuspenseInfiniteQuery<ReviewResponse[]>({
|
||||||
queryKey: ['workspaceReviews', workspaceId, reviewStatus, lastReviewId],
|
queryKey: ['workspaceReviews', workspaceId, reviewStatus],
|
||||||
queryFn: () => getWorkspaceReviews(workspaceId, memberId, reviewStatus, lastReviewId, limitPage),
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user