From 416f55481973aa649ab834c03d26db4f76a0e967 Mon Sep 17 00:00:00 2001 From: You Wei Date: Wed, 13 Nov 2024 02:19:30 +0800 Subject: [PATCH 1/2] Add lazy loading to question fetching --- .../src/app/(main)/components/Main.tsx | 18 ++- peerprep-fe/src/app/collaboration/page.tsx | 4 +- .../src/components/problems/ProblemTable.tsx | 86 ++++++++----- peerprep-fe/src/hooks/useFilteredProblems.ts | 116 +++++++++++++----- .../src/routes/questionsController.ts | 65 ++++++---- 5 files changed, 202 insertions(+), 87 deletions(-) diff --git a/peerprep-fe/src/app/(main)/components/Main.tsx b/peerprep-fe/src/app/(main)/components/Main.tsx index 84fa535020..c2926f7d36 100644 --- a/peerprep-fe/src/app/(main)/components/Main.tsx +++ b/peerprep-fe/src/app/(main)/components/Main.tsx @@ -5,8 +5,15 @@ import ProblemTable from '../../../components/problems/ProblemTable'; import RejoinSession from './RejoinSession'; export default function MainComponent() { - const { problems, filters, updateFilter, removeFilter, isLoading } = - useFilteredProblems(); + const { + problems, + filters, + updateFilter, + removeFilter, + isLoading, + hasMore, + loadMore, + } = useFilteredProblems(); return (
@@ -17,7 +24,12 @@ export default function MainComponent() { updateFilter={updateFilter} removeFilter={removeFilter} /> - +
); diff --git a/peerprep-fe/src/app/collaboration/page.tsx b/peerprep-fe/src/app/collaboration/page.tsx index f733c4d5d9..e48f594d7d 100644 --- a/peerprep-fe/src/app/collaboration/page.tsx +++ b/peerprep-fe/src/app/collaboration/page.tsx @@ -31,7 +31,7 @@ function CollaborationPageContent() { ); const searchParams = useSearchParams(); const matchId = searchParams.get('matchId'); - const { problems, isLoading } = useFilteredProblems(); + const { problems, isLoading, hasMore, loadMore } = useFilteredProblems(); const { setLastMatchId } = useCollaborationStore(); useEffect(() => { @@ -99,6 +99,8 @@ function CollaborationPageContent() { diff --git a/peerprep-fe/src/components/problems/ProblemTable.tsx b/peerprep-fe/src/components/problems/ProblemTable.tsx index d5ce6dc058..8ae815e489 100644 --- a/peerprep-fe/src/components/problems/ProblemTable.tsx +++ b/peerprep-fe/src/components/problems/ProblemTable.tsx @@ -3,11 +3,14 @@ import { Problem } from '@/types/types'; import ProblemRow from './ProblemRow'; import { Skeleton } from '@/components/ui/skeleton'; import { AxiosResponse } from 'axios'; +import { useEffect, useRef } from 'react'; interface ProblemTableProps { problems: Problem[]; isLoading: boolean; showActions?: boolean; + hasMore: boolean; // Add this + onLoadMore: () => void; // Add this handleDelete?: | ((id: number) => Promise>) | undefined; @@ -21,10 +24,35 @@ export default function ProblemTable({ problems, isLoading, showActions = false, + hasMore, + onLoadMore, handleDelete, handleEdit, rowCallback, }: ProblemTableProps) { + const observerTarget = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isLoading && hasMore) { + onLoadMore(); + console.log('Loaded 10 more entries'); + } + }, + { + rootMargin: '100px', + threshold: 0.1, + }, + ); + + if (observerTarget.current) { + observer.observe(observerTarget.current); + } + + return () => observer.disconnect(); + }, [isLoading, hasMore, onLoadMore]); + return (
@@ -37,36 +65,38 @@ export default function ProblemTable({ - {isLoading - ? Array.from({ length: 5 }).map((_, index) => ( - - - - - - )) - : problems.map((problem) => ( - - ))} + {problems.map((problem) => ( + + ))}
- - -
- - - -
-
- -
+ + {/* Observer target and loading indicator */} +
+ {(isLoading || hasMore) && ( +
+ {[...Array(3)].map((_, index) => ( +
+ +
+ ))} +
+ )} +
+ + {/* End of list message */} + {!hasMore && problems.length > 0 && ( +
+ No more problems to load +
+ )}
); } diff --git a/peerprep-fe/src/hooks/useFilteredProblems.ts b/peerprep-fe/src/hooks/useFilteredProblems.ts index c1c2997c4f..f9490c9901 100644 --- a/peerprep-fe/src/hooks/useFilteredProblems.ts +++ b/peerprep-fe/src/hooks/useFilteredProblems.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { axiosClient } from '@/network/axiosClient'; import { Problem } from '@/types/types'; @@ -9,7 +9,10 @@ export interface FilterState { search: string | null; } +const PAGE_SIZE = 20; + export function useFilteredProblems() { + // States for both filtering and pagination const [problems, setProblems] = useState([]); const [filters, setFilters] = useState({ difficulty: null, @@ -18,32 +21,75 @@ export function useFilteredProblems() { search: null, }); const [isLoading, setIsLoading] = useState(true); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const seenIds = useRef(new Set()); - const fetchProblems = useCallback(async () => { - setIsLoading(true); - const params = new URLSearchParams(); - if (filters.difficulty) params.append('difficulty', filters.difficulty); - if (filters.status) params.append('status', filters.status); - if (filters.topics) - filters.topics.forEach((topic) => params.append('topics', topic)); - if (filters.search) params.append('search', filters.search); - try { - const url = params.toString() - ? `/questions?${params.toString()}` - : '/questions'; - const response = await axiosClient.get(url); - setProblems(response.data); - } catch (error) { - console.error('Error fetching problems:', error); - } finally { - setIsLoading(false); - } - }, [filters]); + const fetchProblems = useCallback( + async (pageNum: number, isLoadingMore = false) => { + if (!isLoadingMore) { + seenIds.current.clear(); + } - useEffect(() => { - fetchProblems(); - }, [fetchProblems]); + setIsLoading(true); + + try { + const params = new URLSearchParams(); + params.append('page', pageNum.toString()); + params.append('limit', PAGE_SIZE.toString()); + + // Apply filters to query + if (filters.difficulty) params.append('difficulty', filters.difficulty); + if (filters.status) params.append('status', filters.status); + if (filters.topics?.length) { + filters.topics.forEach((topic) => params.append('topics', topic)); + } + if (filters.search) params.append('search', filters.search); + + const url = `/questions?${params.toString()}`; + const response = await axiosClient.get(url); + const newProblems = response.data; + + if (newProblems.length === 0) { + setHasMore(false); + return; + } + + if (isLoadingMore) { + console.log('Fetching a page of 20 items'); + const uniqueNewProblems: Problem[] = []; + let foundDuplicate = false; + + for (const problem of newProblems) { + if (seenIds.current.has(problem._id)) { + foundDuplicate = true; + break; + } + seenIds.current.add(problem._id); + uniqueNewProblems.push(problem); + } + + if (foundDuplicate || uniqueNewProblems.length === 0) { + setHasMore(false); + } + setProblems((prev) => [...prev, ...uniqueNewProblems]); + } else { + newProblems.forEach((problem) => seenIds.current.add(problem._id)); + setProblems(newProblems); + setHasMore(newProblems.length === PAGE_SIZE); + } + } catch (error) { + console.error('Error fetching problems:', error); + setHasMore(false); + } finally { + setIsLoading(false); + } + }, + [filters], + ); // Note filters dependency + + // Filter functions const updateFilter = useCallback( (key: keyof FilterState, value: string | string[] | null) => { setFilters((prev) => ({ @@ -67,11 +113,20 @@ export function useFilteredProblems() { })); }, []); - const refetchFilter = useCallback(() => { - setFilters((prev) => ({ - ...prev, - })); - }, []); + // Reset and fetch when filters change + useEffect(() => { + setPage(1); + fetchProblems(1, false); + }, [filters, fetchProblems]); + + // Load more function for infinite scroll + const loadMore = useCallback(() => { + if (!isLoading && hasMore) { + const nextPage = page + 1; + setPage(nextPage); + fetchProblems(nextPage, true); + } + }, [isLoading, hasMore, page, fetchProblems]); return { problems, @@ -79,6 +134,7 @@ export function useFilteredProblems() { updateFilter, removeFilter, isLoading, - refetchFilter, + hasMore, + loadMore, }; } diff --git a/question-service/src/routes/questionsController.ts b/question-service/src/routes/questionsController.ts index f9d6d979a3..9590c31107 100644 --- a/question-service/src/routes/questionsController.ts +++ b/question-service/src/routes/questionsController.ts @@ -20,10 +20,41 @@ router.use(async (_, res, next) => { } }); +// GET all unique tags +router.get('/tags', async (_: Request, res: Response) => { + try { + const uniqueTags = await questionsCollection + .aggregate([ + { $unwind: '$tags' }, + { $group: { _id: '$tags' } }, + { $project: { _id: 0, tag: '$_id' } }, + ]) + .toArray(); + + const tags = uniqueTags.map((item) => item.tag); + res.status(200).json(tags); + } catch (error) { + console.error('Error fetching tags:', error); + res.status(500).json({ error: 'Failed to fetch tags' }); + } +}); + // GET all items with filters router.get('/', async (req: Request, res: Response) => { try { - const { difficulty, status, topics, search } = req.query; + const { + difficulty, + status, + topics, + search, + page = '1', + limit = '20', + } = req.query; + + // Add pagination + const pageNumber = parseInt(page as string); + const limitNumber = parseInt(limit as string); + const skip = (pageNumber - 1) * limitNumber; let query: any = {}; if (difficulty) { @@ -37,13 +68,16 @@ router.get('/', async (req: Request, res: Response) => { query.tags = { $in: topicsArray }; } if (search && typeof search === 'string') { - query.$or = [ - { title: { $regex: search, $options: 'i' } }, // only search in title - // { description: { $regex: search, $options: 'i' } }, - ]; + query.$or = [{ title: { $regex: search, $options: 'i' } }]; } - const items = await questionsCollection.find(query).toArray(); + // Add pagination to MongoDB query + const items = await questionsCollection + .find(query) + .skip(skip) + .limit(limitNumber) + .toArray(); + res.status(200).json(items); } catch (error) { res.status(500).json({ error: 'Failed to fetch items' }); @@ -149,23 +183,4 @@ router.delete('/:id', async (req: Request, res: Response) => { } }); -// GET all unique tags -router.get('/tags', async (_: Request, res: Response) => { - try { - const uniqueTags = await questionsCollection - .aggregate([ - { $unwind: '$tags' }, - { $group: { _id: '$tags' } }, - { $project: { _id: 0, tag: '$_id' } }, - ]) - .toArray(); - - const tags = uniqueTags.map((item) => item.tag); - res.status(200).json(tags); - } catch (error) { - console.error('Error fetching tags:', error); - res.status(500).json({ error: 'Failed to fetch tags' }); - } -}); - export default router; From 7f0018fdd9b3d8077fbde60d918adf74a9191e17 Mon Sep 17 00:00:00 2001 From: kurt Date: Wed, 13 Nov 2024 21:07:13 +0800 Subject: [PATCH 2/2] change fetching for admin --- peerprep-fe/src/app/admin/page.tsx | 14 +++++++++----- peerprep-fe/src/components/problems/ProblemRow.tsx | 7 +++++-- peerprep-fe/src/hooks/useFilteredProblems.ts | 1 + 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/peerprep-fe/src/app/admin/page.tsx b/peerprep-fe/src/app/admin/page.tsx index 95d286c606..765900f34c 100644 --- a/peerprep-fe/src/app/admin/page.tsx +++ b/peerprep-fe/src/app/admin/page.tsx @@ -20,7 +20,9 @@ function AdminPage() { updateFilter, removeFilter, isLoading, - refetchFilter, + hasMore, + loadMore, + fetchProblems, } = useFilteredProblems(); const validateEntries = (problem: Problem) => { @@ -40,7 +42,7 @@ function AdminPage() { if (res.status !== 200) { throw new Error('Failed to delete problem'); } - refetchFilter(); + fetchProblems(1, false); return res; }; @@ -59,7 +61,7 @@ function AdminPage() { title: problem.title, }); - refetchFilter(); + fetchProblems(1, false); return res; } catch (e: unknown) { if (isAxiosError(e)) { @@ -97,7 +99,7 @@ function AdminPage() { title: problem.title, }); - refetchFilter(); + fetchProblems(1, false); toggleDialogOpen(); return res; } catch (e: unknown) { @@ -132,9 +134,11 @@ function AdminPage() { { - setIsDeleteDialogOpen(false); handleDeleteClick(); + setIsDeleteDialogOpen(false); }} callbackTitle="Delete" /> @@ -170,7 +170,10 @@ export default function ProblemRow({ isOpen={isEditDialogOpen} onClose={() => setIsEditDialogOpen(false)} problem={problem} - requestCallback={handleEditClick} + requestCallback={(problem) => { + handleEditClick(problem); + setIsEditDialogOpen(false); + }} requestTitle="Update" /> diff --git a/peerprep-fe/src/hooks/useFilteredProblems.ts b/peerprep-fe/src/hooks/useFilteredProblems.ts index f9490c9901..8046a6d3d3 100644 --- a/peerprep-fe/src/hooks/useFilteredProblems.ts +++ b/peerprep-fe/src/hooks/useFilteredProblems.ts @@ -136,5 +136,6 @@ export function useFilteredProblems() { isLoading, hasMore, loadMore, + fetchProblems, }; }