refactor: enhance DocumentsTableShell with infinite scroll support and update DocumentsSidebar for improved search functionality

This commit is contained in:
Anish Sarkar 2026-03-05 23:45:35 +05:30
parent b8f52946fd
commit 0feb17cb75
2 changed files with 201 additions and 92 deletions

View file

@ -298,6 +298,9 @@ export function DocumentsTableShell({
onSortChange, onSortChange,
deleteDocument, deleteDocument,
searchSpaceId, searchSpaceId,
hasMore = false,
loadingMore = false,
onLoadMore,
}: { }: {
documents: Document[]; documents: Document[];
loading: boolean; loading: boolean;
@ -310,6 +313,9 @@ export function DocumentsTableShell({
onSortChange: (key: SortKey) => void; onSortChange: (key: SortKey) => void;
deleteDocument: (id: number) => Promise<boolean>; deleteDocument: (id: number) => Promise<boolean>;
searchSpaceId: string; searchSpaceId: string;
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => void;
}) { }) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog(); const { openDialog } = useDocumentUploadDialog();
@ -321,6 +327,31 @@ export function DocumentsTableShell({
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null); const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const desktopSentinelRef = useRef<HTMLDivElement>(null);
const mobileSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!onLoadMore || !hasMore || loadingMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
onLoadMore();
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
if (desktopSentinelRef.current) {
observer.observe(desktopSentinelRef.current);
}
if (mobileSentinelRef.current) {
observer.observe(mobileSentinelRef.current);
}
return () => observer.disconnect();
}, [onLoadMore, hasMore, loadingMore]);
const handleViewDocument = useCallback(async (doc: Document) => { const handleViewDocument = useCallback(async (doc: Document) => {
setViewingDoc(doc); setViewingDoc(doc);
if (doc.content) { if (doc.content) {
@ -410,7 +441,7 @@ export function DocumentsTableShell({
return ( return (
<motion.div <motion.div
className="bg-background overflow-hidden select-none border-t border-border/50" className="bg-background overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }} transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
@ -418,7 +449,7 @@ export function DocumentsTableShell({
{loading ? ( {loading ? (
<> <>
{/* Desktop Skeleton */} {/* Desktop Skeleton */}
<div className="hidden md:flex md:flex-col"> <div className="hidden md:flex md:flex-col flex-1 min-h-0">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/50"> <TableRow className="hover:bg-transparent border-b border-border/50">
@ -439,7 +470,7 @@ export function DocumentsTableShell({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
</Table> </Table>
<div className="h-[50vh] overflow-auto"> <div className="flex-1 overflow-auto">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableBody> <TableBody>
{[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => ( {[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => (
@ -468,7 +499,7 @@ export function DocumentsTableShell({
</div> </div>
</div> </div>
{/* Mobile Skeleton */} {/* Mobile Skeleton */}
<div className="md:hidden divide-y divide-border/50 h-[50vh] overflow-auto"> <div className="md:hidden divide-y divide-border/50 flex-1 overflow-auto">
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => ( {[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
<div key={`skeleton-mobile-${index}`} className="px-3 py-2"> <div key={`skeleton-mobile-${index}`} className="px-3 py-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -486,14 +517,14 @@ export function DocumentsTableShell({
</div> </div>
</> </>
) : error ? ( ) : error ? (
<div className="flex h-[50vh] w-full items-center justify-center"> <div className="flex flex-1 w-full items-center justify-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">{t("error_loading")}</p> <p className="text-sm text-destructive">{t("error_loading")}</p>
</div> </div>
</div> </div>
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<div className="flex h-[50vh] w-full items-center justify-center"> <div className="flex flex-1 w-full items-center justify-center">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -518,7 +549,7 @@ export function DocumentsTableShell({
) : ( ) : (
<> <>
{/* Desktop Table View */} {/* Desktop Table View */}
<div className="hidden md:flex md:flex-col"> <div className="hidden md:flex md:flex-col flex-1 min-h-0">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/50"> <TableRow className="hover:bg-transparent border-b border-border/50">
@ -556,7 +587,7 @@ export function DocumentsTableShell({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
</Table> </Table>
<div className="h-[50vh] overflow-auto"> <div className="flex-1 overflow-auto">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableBody> <TableBody>
{sorted.map((doc, index) => { {sorted.map((doc, index) => {
@ -571,14 +602,9 @@ export function DocumentsTableShell({
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
> >
<motion.tr <motion.tr
initial={{ opacity: 0 }} initial={index < 20 ? { opacity: 0 } : false}
animate={{ animate={{ opacity: 1 }}
opacity: 1, transition={index < 20 ? { duration: 0.15, delay: index * 0.02 } : { duration: 0 }}
transition: {
duration: 0.2,
delay: index * 0.02,
},
}}
className={`border-b border-border/50 transition-colors ${ className={`border-b border-border/50 transition-colors ${
isSelected isSelected
? "bg-primary/5 hover:bg-primary/8" ? "bg-primary/5 hover:bg-primary/8"
@ -632,11 +658,16 @@ export function DocumentsTableShell({
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> {hasMore && (
<div ref={desktopSentinelRef} className="flex items-center justify-center py-3">
{loadingMore && <Spinner size="sm" className="text-muted-foreground" />}
</div>
)}
</div> </div>
</div>
{/* Mobile Card View */} {/* Mobile Card View */}
<div className="md:hidden divide-y divide-border/50 h-[50vh] overflow-auto"> <div className="md:hidden divide-y divide-border/50 flex-1 overflow-auto">
{sorted.map((doc, index) => { {sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id); const isSelected = selectedIds.has(doc.id);
const canSelect = isSelectable(doc); const canSelect = isSelectable(doc);
@ -649,8 +680,9 @@ export function DocumentsTableShell({
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
> >
<motion.div <motion.div
initial={{ opacity: 0 }} initial={index < 20 ? { opacity: 0 } : false}
animate={{ opacity: 1, transition: { delay: index * 0.03 } }} animate={{ opacity: 1 }}
transition={index < 20 ? { duration: 0.15, delay: index * 0.03 } : { duration: 0 }}
className={`px-3 py-2 transition-colors ${ className={`px-3 py-2 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/20" isSelected ? "bg-primary/5" : "hover:bg-muted/20"
}`} }`}
@ -696,7 +728,12 @@ export function DocumentsTableShell({
</RowContextMenu> </RowContextMenu>
); );
})} })}
</div> {hasMore && (
<div ref={mobileSentinelRef} className="flex items-center justify-center py-3">
{loadingMore && <Spinner size="sm" className="text-muted-foreground" />}
</div>
)}
</div>
</> </>
)} )}

View file

@ -1,11 +1,10 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { ChevronLeft, SquareLibrary } from "lucide-react"; import { ChevronLeft, SquareLibrary } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -13,7 +12,6 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDocuments } from "@/hooks/use-documents"; import { useDocuments } from "@/hooks/use-documents";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { import {
DocumentsFilters, DocumentsFilters,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
@ -21,13 +19,12 @@ import {
DocumentsTableShell, DocumentsTableShell,
type SortKey, type SortKey,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
import {
PAGE_SIZE,
PaginationControls,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls";
import type { ColumnVisibility } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/types"; import type { ColumnVisibility } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/types";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const INITIAL_LOAD_SIZE = 20;
const SCROLL_LOAD_SIZE = 5;
function useDebounced<T>(value: T, delay = 250) { function useDebounced<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
@ -58,7 +55,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
created_at: true, created_at: true,
status: true, status: true,
}); });
const [pageIndex, setPageIndex] = useState(0);
const [sortKey, setSortKey] = useState<SortKey>("created_at"); const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true); const [sortDesc, setSortDesc] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
@ -73,28 +69,24 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const isSearchMode = !!debouncedSearch.trim(); const isSearchMode = !!debouncedSearch.trim();
const searchQueryParams = useMemo( // --- Infinite scroll state ---
() => ({ const [visibleCount, setVisibleCount] = useState(INITIAL_LOAD_SIZE);
search_space_id: searchSpaceId, const [searchItems, setSearchItems] = useState<Array<{
page: pageIndex, id: number;
page_size: PAGE_SIZE, search_space_id: number;
title: debouncedSearch.trim(), document_type: string;
...(activeTypes.length > 0 && { document_types: activeTypes }), title: string;
}), created_by_id: string | null;
[searchSpaceId, pageIndex, activeTypes, debouncedSearch] created_by_name: string | null;
); created_by_email: string | null;
created_at: string;
const { status: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
data: searchResponse, }>>([]);
isLoading: isSearchLoading, const [searchTotal, setSearchTotal] = useState(0);
refetch: refetchSearch, const [searchPageIndex, setSearchPageIndex] = useState(0);
error: searchError, const [searchLoadingMore, setSearchLoadingMore] = useState(false);
} = useQuery({ const [searchInitialLoading, setSearchInitialLoading] = useState(false);
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), const searchQueryRef = useRef(debouncedSearch);
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 30 * 1000,
enabled: !!searchSpaceId && isSearchMode && open,
});
const sortedRealtimeDocuments = useMemo(() => { const sortedRealtimeDocuments = useMemo(() => {
const docs = [...realtimeDocuments]; const docs = [...realtimeDocuments];
@ -112,14 +104,82 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
return docs; return docs;
}, [realtimeDocuments, sortKey, sortDesc]); }, [realtimeDocuments, sortKey, sortDesc]);
const paginatedRealtimeDocuments = useMemo(() => { // Reset visible count when sort/filter changes
const start = pageIndex * PAGE_SIZE; // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset
const end = start + PAGE_SIZE; useEffect(() => {
return sortedRealtimeDocuments.slice(start, end); setVisibleCount(INITIAL_LOAD_SIZE);
}, [sortedRealtimeDocuments, pageIndex]); }, [sortKey, sortDesc, activeTypes]);
const displayDocs = isSearchMode // Initial search fetch when search query changes
? (searchResponse?.items || []).map((item) => ({ useEffect(() => {
if (!isSearchMode || !searchSpaceId || !open) {
setSearchItems([]);
setSearchTotal(0);
setSearchPageIndex(0);
return;
}
searchQueryRef.current = debouncedSearch;
setSearchInitialLoading(true);
const queryParams = {
search_space_id: searchSpaceId,
page: 0,
page_size: INITIAL_LOAD_SIZE,
title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }),
};
documentsApiService
.searchDocuments({ queryParams })
.then((response) => {
if (searchQueryRef.current !== debouncedSearch) return;
const mapped = response.items.map((item) => ({
id: item.id,
search_space_id: item.search_space_id,
document_type: item.document_type,
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: (
item as {
status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
}
).status ?? { state: "ready" as const },
}));
setSearchItems(mapped);
setSearchTotal(response.total);
setSearchPageIndex(0);
})
.catch((err) => {
console.error("[DocumentsSidebar] Search failed:", err);
})
.finally(() => {
setSearchInitialLoading(false);
});
}, [debouncedSearch, searchSpaceId, open, isSearchMode, activeTypes]);
// Load more search results
const loadMoreSearch = useCallback(async () => {
if (searchLoadingMore || !isSearchMode) return;
const nextPage = searchPageIndex + 1;
if (searchItems.length >= searchTotal) return;
setSearchLoadingMore(true);
try {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: SCROLL_LOAD_SIZE,
title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }),
};
const response = await documentsApiService.searchDocuments({ queryParams });
if (searchQueryRef.current !== debouncedSearch) return;
const mapped = response.items.map((item) => ({
id: item.id, id: item.id,
search_space_id: item.search_space_id, search_space_id: item.search_space_id,
document_type: item.document_type, document_type: item.document_type,
@ -133,13 +193,38 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string }; status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
} }
).status ?? { state: "ready" as const }, ).status ?? { state: "ready" as const },
})) }));
: paginatedRealtimeDocuments; setSearchItems((prev) => [...prev, ...mapped]);
setSearchTotal(response.total);
setSearchPageIndex(nextPage);
} catch (err) {
console.error("[DocumentsSidebar] Load more search failed:", err);
} finally {
setSearchLoadingMore(false);
}
}, [searchLoadingMore, isSearchMode, searchPageIndex, searchItems.length, searchTotal, searchSpaceId, debouncedSearch, activeTypes]);
const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length; // Load more for realtime (client-side, just increase visible count)
const loading = isSearchMode ? isSearchLoading : realtimeLoading; const loadMoreRealtime = useCallback(() => {
const error = isSearchMode ? searchError : realtimeError; setVisibleCount((prev) => Math.min(prev + SCROLL_LOAD_SIZE, sortedRealtimeDocuments.length));
const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal); }, [sortedRealtimeDocuments.length]);
const visibleRealtimeDocs = useMemo(
() => sortedRealtimeDocuments.slice(0, visibleCount),
[sortedRealtimeDocuments, visibleCount]
);
const displayDocs = isSearchMode ? searchItems : visibleRealtimeDocs;
const loading = isSearchMode ? searchInitialLoading : realtimeLoading;
const error = isSearchMode ? false : realtimeError;
const hasMore = isSearchMode
? searchItems.length < searchTotal
: visibleCount < sortedRealtimeDocuments.length;
const loadingMore = isSearchMode ? searchLoadingMore : false;
const onLoadMore = isSearchMode ? loadMoreSearch : loadMoreRealtime;
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => { setActiveTypes((prev) => {
@ -148,7 +233,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
} }
return prev.filter((t) => t !== type); return prev.filter((t) => t !== type);
}); });
setPageIndex(0);
setSelectedIds(new Set()); setSelectedIds(new Set());
}; };
@ -159,10 +243,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
} }
const allDocs = isSearchMode const allDocs = isSearchMode
? (searchResponse?.items || []).map((item) => ({ ? searchItems.map((item) => ({ id: item.id, status: item.status }))
id: item.id,
status: (item as { status?: { state: string } }).status,
}))
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status })); : sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id)); const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
@ -203,7 +284,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
} else { } else {
toast.error(t("delete_partial_failed")); toast.error(t("delete_partial_failed"));
} }
if (isSearchMode) await refetchSearch(); if (isSearchMode) {
setSearchItems((prev) => prev.filter((item) => !deletableIds.includes(item.id)));
setSearchTotal((prev) => prev - okCount);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -216,14 +300,17 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
try { try {
await deleteDocumentMutation({ id }); await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted"); toast.success(t("delete_success") || "Document deleted");
if (isSearchMode) await refetchSearch(); if (isSearchMode) {
setSearchItems((prev) => prev.filter((item) => item.id !== id));
setSearchTotal((prev) => prev - 1);
}
return true; return true;
} catch (e) { } catch (e) {
console.error("Error deleting document:", e); console.error("Error deleting document:", e);
return false; return false;
} }
}, },
[deleteDocumentMutation, isSearchMode, refetchSearch, t] [deleteDocumentMutation, isSearchMode, t]
); );
const handleSortChange = useCallback((key: SortKey) => { const handleSortChange = useCallback((key: SortKey) => {
@ -237,11 +324,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
}); });
}, []); }, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: Reset page on search change
useEffect(() => {
setPageIndex(0);
}, [debouncedSearch]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const panelWidth = isMobile ? window.innerWidth : 720; const panelWidth = isMobile ? window.innerWidth : 720;
@ -281,7 +363,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto overflow-x-hidden pt-0"> <div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<DocumentsFilters <DocumentsFilters
typeCounts={realtimeTypeCounts} typeCounts={realtimeTypeCounts}
@ -306,20 +388,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
onSortChange={handleSortChange} onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument} deleteDocument={handleDeleteDocument}
searchSpaceId={String(searchSpaceId)} searchSpaceId={String(searchSpaceId)}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
/> />
<div className="px-4 py-2">
<PaginationControls
pageIndex={pageIndex}
total={displayTotal}
onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))}
canPrev={pageIndex > 0}
canNext={pageEnd < displayTotal}
/>
</div>
</div> </div>
</> </>
); );