This commit is contained in:
Sanwal Sulehrii 2025-09-21 04:54:54 +00:00 committed by GitHub
commit b167a36771
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 511 additions and 420 deletions

View file

@ -26,10 +26,8 @@ export default function DocumentsTable() {
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const { documents, loading, error, refreshDocuments, deleteDocument } = const [pageIndex, setPageIndex] = useState(0);
useDocuments(searchSpaceId); const [pageSize, setPageSize] = useState(100); // Increased page size for better performance
const [data, setData] = useState<Document[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250); const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState<string[]>([]); const [activeTypes, setActiveTypes] = useState<string[]>([]);
@ -39,16 +37,28 @@ export default function DocumentsTable() {
content: true, content: true,
created_at: true, created_at: true,
}); });
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortKey, setSortKey] = useState<SortKey>("title"); const [sortKey, setSortKey] = useState<SortKey>("title");
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Calculate skip value for pagination
const skip = pageIndex * pageSize;
const { documents, loading, error, refreshDocuments, deleteDocument } = useDocuments(
searchSpaceId,
false,
skip,
pageSize
);
const [data, setData] = useState<Document[]>([]);
useEffect(() => { useEffect(() => {
if (documents) setData(documents as Document[]); if (documents) setData(documents as Document[]);
}, [documents]); }, [documents]);
// For server-side pagination, we apply client-side filtering to the current page data
// Note: This is a simplified approach. For production, you might want to implement server-side filtering
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = data; let result = data;
if (debouncedSearch.trim()) { if (debouncedSearch.trim()) {
@ -61,16 +71,21 @@ export default function DocumentsTable() {
return result; return result;
}, [data, debouncedSearch, activeTypes]); }, [data, debouncedSearch, activeTypes]);
const total = filtered.length; // For server-side pagination, we show the filtered results from current page
const pageStart = pageIndex * pageSize; // Total count is estimated based on current page size (this could be improved with a count endpoint)
const pageEnd = Math.min(pageStart + pageSize, total); const pageDocs = filtered;
const pageDocs = filtered.slice(pageStart, pageEnd); const total = pageSize * (pageIndex + 1); // Estimated total, could be improved with actual count
const onToggleType = (type: string, checked: boolean) => { const onToggleType = (type: string, checked: boolean) => {
setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type)));
setPageIndex(0); setPageIndex(0);
}; };
// Reset to first page when search changes
useEffect(() => {
setPageIndex(0);
}, [debouncedSearch]);
const onToggleColumn = (id: keyof ColumnVisibility, checked: boolean) => { const onToggleColumn = (id: keyof ColumnVisibility, checked: boolean) => {
setColumnVisibility((prev) => ({ ...prev, [id]: checked })); setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
}; };
@ -157,10 +172,10 @@ export default function DocumentsTable() {
}} }}
onFirst={() => setPageIndex(0)} onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
onNext={() => setPageIndex((i) => (pageEnd < total ? i + 1 : i))} onNext={() => setPageIndex((i) => i + 1)}
onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))} onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))}
canPrev={pageIndex > 0} canPrev={pageIndex > 0}
canNext={pageEnd < total} canNext={pageDocs.length === pageSize} // Show next if current page is full
id={id} id={id}
/> />
</motion.div> </motion.div>

View file

@ -39,11 +39,25 @@ const DocumentSelector = React.memo(
}) => { }) => {
const { search_space_id } = useParams(); const { search_space_id } = useParams();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(100); // Larger page size for document selector
const { documents, loading, isLoaded, fetchDocuments } = useDocuments( // Calculate skip value for pagination
Number(search_space_id), const skip = pageIndex * pageSize;
true
); const { documents, loading, isLoaded, fetchDocuments, error } = useDocuments(
Number(search_space_id),
true,
skip,
pageSize
);
// Refetch documents when pagination changes
useEffect(() => {
if (isOpen) {
fetchDocuments();
}
}, [skip, pageSize, isOpen, fetchDocuments]);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
@ -98,12 +112,29 @@ const DocumentSelector = React.memo(
<p className="text-sm text-muted-foreground">Loading documents...</p> <p className="text-sm text-muted-foreground">Loading documents...</p>
</div> </div>
</div> </div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-sm text-destructive">Error loading documents: {error}</p>
<Button onClick={fetchDocuments} variant="outline" size="sm">
Retry
</Button>
</div>
</div>
) : isLoaded ? ( ) : isLoaded ? (
<DocumentsDataTable <DocumentsDataTable
documents={documents} documents={documents}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onDone={handleDone} onDone={handleDone}
initialSelectedDocuments={selectedDocuments} initialSelectedDocuments={selectedDocuments}
pageIndex={pageIndex}
pageSize={pageSize}
onPageIndexChange={setPageIndex}
onPageSizeChange={(newSize) => {
setPageSize(newSize);
setPageIndex(0);
}}
canNext={documents.length === pageSize}
/> />
) : null} ) : null}
</div> </div>

View file

@ -1,16 +1,16 @@
"use client"; "use client";
import { import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState, type ColumnFiltersState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
type SortingState, type SortingState,
useReactTable, useReactTable,
type VisibilityState, type VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ArrowUpDown, Calendar, FileText, Search } from "lucide-react"; import { ArrowUpDown, Calendar, FileText, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@ -18,423 +18,462 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document, DocumentType } from "@/hooks/use-documents"; import type { Document, DocumentType } from "@/hooks/use-documents";
interface DocumentsDataTableProps { interface DocumentsDataTableProps {
documents: Document[]; documents: Document[];
onSelectionChange: (documents: Document[]) => void; onSelectionChange: (documents: Document[]) => void;
onDone: () => void; onDone: () => void;
initialSelectedDocuments?: Document[]; initialSelectedDocuments?: Document[];
pageIndex?: number;
pageSize?: number;
onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
canNext?: boolean;
} }
// Combine EnumConnectorName with additional document types // Combine EnumConnectorName with additional document types
const DOCUMENT_TYPES: (string | "ALL")[] = [ const DOCUMENT_TYPES: (string | "ALL")[] = [
"ALL", "ALL",
"FILE", "FILE",
"EXTENSION", "EXTENSION",
"CRAWLED_URL", "CRAWLED_URL",
"YOUTUBE_VIDEO", "YOUTUBE_VIDEO",
...Object.values(EnumConnectorName), ...Object.values(EnumConnectorName),
]; ];
const columns: ColumnDef<Document>[] = [ const columns: ColumnDef<Document>[] = [
{ {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={ checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") table.getIsAllPageRowsSelected() ||
} (table.getIsSomePageRowsSelected() && "indeterminate")
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} }
aria-label="Select all" onCheckedChange={(value) => {
/> table.toggleAllPageRowsSelected(!!value);
), }}
cell: ({ row }) => ( aria-label="Select all"
<Checkbox />
checked={row.getIsSelected()} ),
onCheckedChange={(value) => row.toggleSelected(!!value)} cell: ({ row }) => (
aria-label="Select row" <Checkbox
/> checked={row.getIsSelected()}
), onCheckedChange={(value) => {
enableSorting: false, row.toggleSelected(!!value);
enableHiding: false, }}
size: 40, aria-label="Select row"
}, />
{ ),
accessorKey: "title", enableSorting: false,
header: ({ column }) => ( enableHiding: false,
<Button size: 40,
variant="ghost" },
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} {
className="h-8 px-1 sm:px-2 font-medium text-left justify-start" accessorKey: "title",
> header: ({ column }) => (
<FileText className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" /> <Button
<span className="hidden sm:inline">Title</span> variant="ghost"
<span className="sm:hidden">Doc</span> onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" /> className="h-8 px-1 sm:px-2 font-medium text-left justify-start"
</Button> >
), <FileText className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
cell: ({ row }) => { <span className="hidden sm:inline">Title</span>
const title = row.getValue("title") as string; <span className="sm:hidden">Doc</span>
return ( <ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
<div </Button>
className="font-medium max-w-[120px] sm:max-w-[250px] truncate text-xs sm:text-sm" ),
title={title} cell: ({ row }) => {
> const title = row.getValue("title") as string;
{title} return (
</div> <div
); className="font-medium max-w-[120px] sm:max-w-[250px] truncate text-xs sm:text-sm"
}, title={title}
}, >
{ {title}
accessorKey: "document_type", </div>
header: "Type", );
cell: ({ row }) => { },
const type = row.getValue("document_type") as DocumentType; },
return ( {
<div className="flex items-center gap-2" title={type}> accessorKey: "document_type",
<span className="text-primary">{getConnectorIcon(type)}</span> header: "Type",
</div> cell: ({ row }) => {
); const type = row.getValue("document_type") as DocumentType;
}, return (
size: 80, <div className="flex items-center gap-2" title={type}>
meta: { <span className="text-primary">{getConnectorIcon(type)}</span>
className: "hidden sm:table-cell", </div>
}, );
}, },
{ size: 80,
accessorKey: "content", meta: {
header: "Preview", className: "hidden sm:table-cell",
cell: ({ row }) => { },
const content = row.getValue("content") as string; },
return ( {
<div accessorKey: "content",
className="text-muted-foreground max-w-[150px] sm:max-w-[350px] truncate text-[10px] sm:text-sm" header: "Preview",
title={content} cell: ({ row }) => {
> const content = row.getValue("content") as string;
<span className="sm:hidden">{content.substring(0, 30)}...</span> return (
<span className="hidden sm:inline">{content.substring(0, 100)}...</span> <div
</div> className="text-muted-foreground max-w-[150px] sm:max-w-[350px] truncate text-[10px] sm:text-sm"
); title={content}
}, >
enableSorting: false, <span className="sm:hidden">{content.substring(0, 30)}...</span>
meta: { <span className="hidden sm:inline">
className: "hidden md:table-cell", {content.substring(0, 100)}...
}, </span>
}, </div>
{ );
accessorKey: "created_at", },
header: ({ column }) => ( enableSorting: false,
<Button meta: {
variant="ghost" className: "hidden md:table-cell",
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} },
className="h-8 px-1 sm:px-2 font-medium" },
> {
<Calendar className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" /> accessorKey: "created_at",
<span className="hidden sm:inline">Created</span> header: ({ column }) => (
<span className="sm:hidden">Date</span> <Button
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" /> variant="ghost"
</Button> onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
), className="h-8 px-1 sm:px-2 font-medium"
cell: ({ row }) => { >
const date = new Date(row.getValue("created_at")); <Calendar className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
return ( <span className="hidden sm:inline">Created</span>
<div className="text-xs sm:text-sm whitespace-nowrap"> <span className="sm:hidden">Date</span>
<span className="hidden sm:inline"> <ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
{date.toLocaleDateString("en-US", { </Button>
month: "short", ),
day: "numeric", cell: ({ row }) => {
year: "numeric", const date = new Date(row.getValue("created_at"));
})} return (
</span> <div className="text-xs sm:text-sm whitespace-nowrap">
<span className="sm:hidden"> <span className="hidden sm:inline">
{date.toLocaleDateString("en-US", { {date.toLocaleDateString("en-US", {
month: "numeric", month: "short",
day: "numeric", day: "numeric",
})} year: "numeric",
</span> })}
</div> </span>
); <span className="sm:hidden">
}, {date.toLocaleDateString("en-US", {
size: 80, month: "numeric",
}, day: "numeric",
})}
</span>
</div>
);
},
size: 80,
},
]; ];
export function DocumentsDataTable({ export function DocumentsDataTable({
documents, documents,
onSelectionChange, onSelectionChange,
onDone, onDone,
initialSelectedDocuments = [], initialSelectedDocuments = [],
pageIndex = 0,
pageSize = 100,
onPageIndexChange,
onPageSizeChange,
canNext = false,
}: DocumentsDataTableProps) { }: DocumentsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [documentTypeFilter, setDocumentTypeFilter] = useState<string | "ALL">("ALL"); const [documentTypeFilter, setDocumentTypeFilter] = useState<string | "ALL">(
"ALL"
);
// Memoize initial row selection to prevent infinite loops // Memoize initial row selection to prevent infinite loops
const initialRowSelection = useMemo(() => { const initialRowSelection = useMemo(() => {
if (!documents.length || !initialSelectedDocuments.length) return {}; if (!documents.length || !initialSelectedDocuments.length) return {};
const selection: Record<string, boolean> = {}; const selection: Record<string, boolean> = {};
initialSelectedDocuments.forEach((selectedDoc) => { initialSelectedDocuments.forEach((selectedDoc) => {
selection[selectedDoc.id] = true; // Find the document in the current documents array to get the correct row ID
}); const docInCurrentList = documents.find(
return selection; (doc) => doc.id === selectedDoc.id
}, [documents, initialSelectedDocuments]); );
if (docInCurrentList) {
selection[docInCurrentList.id.toString()] = true;
}
});
return selection;
}, [documents, initialSelectedDocuments]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
() => initialRowSelection
);
// Only update row selection when initialRowSelection actually changes and is not empty // Update row selection when initial selection changes
useEffect(() => { useEffect(() => {
const hasChanges = JSON.stringify(rowSelection) !== JSON.stringify(initialRowSelection); setRowSelection(initialRowSelection);
if (hasChanges && Object.keys(initialRowSelection).length > 0) { }, [initialRowSelection]);
setRowSelection(initialRowSelection);
}
}, [initialRowSelection]);
// Initialize row selection on mount const filteredDocuments = useMemo(() => {
useEffect(() => { if (documentTypeFilter === "ALL") return documents;
if (Object.keys(rowSelection).length === 0 && Object.keys(initialRowSelection).length > 0) { return documents.filter((doc) => doc.document_type === documentTypeFilter);
setRowSelection(initialRowSelection); }, [documents, documentTypeFilter]);
}
}, []);
const filteredDocuments = useMemo(() => { const table = useReactTable({
if (documentTypeFilter === "ALL") return documents; data: filteredDocuments,
return documents.filter((doc) => doc.document_type === documentTypeFilter); columns,
}, [documents, documentTypeFilter]); getRowId: (row) => row.id.toString(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
// Disable internal pagination since we're handling it at the parent level
manualPagination: true,
pageCount: -1,
enableRowSelection: true,
state: { sorting, columnFilters, columnVisibility, rowSelection },
});
const table = useReactTable({ useEffect(() => {
data: filteredDocuments, const selectedRows = table.getFilteredSelectedRowModel().rows;
columns, const selectedDocuments = selectedRows.map((row) => row.original);
getRowId: (row) => row.id.toString(), onSelectionChange(selectedDocuments);
onSortingChange: setSorting, }, [rowSelection, table]);
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
initialState: { pagination: { pageSize: 10 } },
state: { sorting, columnFilters, columnVisibility, rowSelection },
});
useEffect(() => { const handleClearAll = () => setRowSelection({});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedDocuments = selectedRows.map((row) => row.original);
onSelectionChange(selectedDocuments);
}, [rowSelection, onSelectionChange, table]);
const handleClearAll = () => setRowSelection({}); const handleSelectPage = () => {
const currentPageRows = table.getRowModel().rows;
const newSelection = { ...rowSelection };
currentPageRows.forEach((row) => {
newSelection[row.id] = true;
});
setRowSelection(newSelection);
};
const handleSelectPage = () => { const handleSelectAllFiltered = () => {
const currentPageRows = table.getRowModel().rows; const allFilteredRows = table.getFilteredRowModel().rows;
const newSelection = { ...rowSelection }; const newSelection: Record<string, boolean> = {};
currentPageRows.forEach((row) => { allFilteredRows.forEach((row) => {
newSelection[row.id] = true; newSelection[row.id] = true;
}); });
setRowSelection(newSelection); setRowSelection(newSelection);
}; };
const handleSelectAllFiltered = () => { const selectedCount = table.getFilteredSelectedRowModel().rows.length;
const allFilteredRows = table.getFilteredRowModel().rows; const totalFiltered = table.getFilteredRowModel().rows.length;
const newSelection: Record<string, boolean> = {};
allFilteredRows.forEach((row) => {
newSelection[row.id] = true;
});
setRowSelection(newSelection);
};
const selectedCount = table.getFilteredSelectedRowModel().rows.length; return (
const totalFiltered = table.getFilteredRowModel().rows.length; <div className="flex flex-col h-full space-y-3 md:space-y-4">
{/* Header Controls */}
<div className="space-y-3 md:space-y-4 flex-shrink-0">
{/* Search and Filter Row */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="relative flex-1 max-w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search documents..."
value={
(table.getColumn("title")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("title")?.setFilterValue(event.target.value)
}
className="pl-10 text-sm"
/>
</div>
<Select
value={documentTypeFilter}
onValueChange={(value) =>
setDocumentTypeFilter(value as string | "ALL")
}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOCUMENT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type === "ALL" ? "All Types" : type.replace(/_/g, " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
return ( {/* Action Controls Row */}
<div className="flex flex-col h-full space-y-3 md:space-y-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* Header Controls */} <div className="flex flex-col sm:flex-row sm:items-center gap-2">
<div className="space-y-3 md:space-y-4 flex-shrink-0"> <span className="text-sm text-muted-foreground whitespace-nowrap">
{/* Search and Filter Row */} {selectedCount} of {totalFiltered} selected
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4"> </span>
<div className="relative flex-1 max-w-full sm:max-w-sm"> <div className="hidden sm:block h-4 w-px bg-border mx-2" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2 flex-wrap">
<Input <Button
placeholder="Search documents..." variant="ghost"
value={(table.getColumn("title")?.getFilterValue() as string) ?? ""} size="sm"
onChange={(event) => table.getColumn("title")?.setFilterValue(event.target.value)} onClick={handleClearAll}
className="pl-10 text-sm" disabled={selectedCount === 0}
/> className="text-xs sm:text-sm"
</div> >
<Select Clear All
value={documentTypeFilter} </Button>
onValueChange={(value) => setDocumentTypeFilter(value as string | "ALL")} <Button
> variant="ghost"
<SelectTrigger className="w-full sm:w-[180px]"> size="sm"
<SelectValue /> onClick={handleSelectPage}
</SelectTrigger> className="text-xs sm:text-sm"
<SelectContent> >
{DOCUMENT_TYPES.map((type) => ( Select Page
<SelectItem key={type} value={type}> </Button>
{type === "ALL" ? "All Types" : type.replace(/_/g, " ")} <Button
</SelectItem> variant="ghost"
))} size="sm"
</SelectContent> onClick={handleSelectAllFiltered}
</Select> className="text-xs sm:text-sm hidden sm:inline-flex"
</div> >
Select All Filtered
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAllFiltered}
className="text-xs sm:hidden"
>
Select All
</Button>
</div>
</div>
<Button
onClick={onDone}
disabled={selectedCount === 0}
className="w-full sm:w-auto sm:min-w-[100px]"
>
Done ({selectedCount})
</Button>
</div>
</div>
{/* Action Controls Row */} {/* Table Container */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="overflow-auto h-full">
<span className="text-sm text-muted-foreground whitespace-nowrap"> <Table>
{selectedCount} of {totalFiltered} selected <TableHeader className="sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10">
</span> {table.getHeaderGroups().map((headerGroup) => (
<div className="hidden sm:block h-4 w-px bg-border mx-2" /> <TableRow key={headerGroup.id} className="border-b">
<div className="flex items-center gap-2 flex-wrap"> {headerGroup.headers.map((header) => (
<Button <TableHead
variant="ghost" key={header.id}
size="sm" className="h-12 text-xs sm:text-sm"
onClick={handleClearAll} >
disabled={selectedCount === 0} {header.isPlaceholder
className="text-xs sm:text-sm" ? null
> : flexRender(
Clear All header.column.columnDef.header,
</Button> header.getContext()
<Button )}
variant="ghost" </TableHead>
size="sm" ))}
onClick={handleSelectPage} </TableRow>
className="text-xs sm:text-sm" ))}
> </TableHeader>
Select Page <TableBody>
</Button> {table.getRowModel().rows?.length ? (
<Button table.getRowModel().rows.map((row) => (
variant="ghost" <TableRow
size="sm" key={row.id}
onClick={handleSelectAllFiltered} data-state={row.getIsSelected() && "selected"}
className="text-xs sm:text-sm hidden sm:inline-flex" className="hover:bg-muted/30"
> >
Select All Filtered {row.getVisibleCells().map((cell) => (
</Button> <TableCell
<Button key={cell.id}
variant="ghost" className="py-3 text-xs sm:text-sm"
size="sm" >
onClick={handleSelectAllFiltered} {flexRender(
className="text-xs sm:hidden" cell.column.columnDef.cell,
> cell.getContext()
Select All )}
</Button> </TableCell>
</div> ))}
</div> </TableRow>
<Button ))
onClick={onDone} ) : (
disabled={selectedCount === 0} <TableRow>
className="w-full sm:w-auto sm:min-w-[100px]" <TableCell
> colSpan={columns.length}
Done ({selectedCount}) className="h-32 text-center text-muted-foreground text-sm"
</Button> >
</div> No documents found.
</div> </TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Table Container */} {/* Footer Pagination */}
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
<div className="overflow-auto h-full"> <div className="text-center sm:text-left">
<Table> Showing {pageIndex * pageSize + 1} to{" "}
<TableHeader className="sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10"> {Math.min(
{table.getHeaderGroups().map((headerGroup) => ( (pageIndex + 1) * pageSize,
<TableRow key={headerGroup.id} className="border-b"> pageIndex * pageSize + documents.length
{headerGroup.headers.map((header) => ( )}{" "}
<TableHead key={header.id} className="h-12 text-xs sm:text-sm"> of{" "}
{header.isPlaceholder {canNext
? null ? `${(pageIndex + 1) * pageSize}+`
: flexRender(header.column.columnDef.header, header.getContext())} : `${pageIndex * pageSize + documents.length}`}{" "}
</TableHead> documents
))} </div>
</TableRow> <div className="flex items-center justify-center sm:justify-end space-x-2">
))} <Button
</TableHeader> variant="outline"
<TableBody> size="sm"
{table.getRowModel().rows?.length ? ( onClick={() => onPageIndexChange?.(pageIndex - 1)}
table.getRowModel().rows.map((row) => ( disabled={pageIndex === 0}
<TableRow className="text-xs sm:text-sm"
key={row.id} >
data-state={row.getIsSelected() && "selected"} Previous
className="hover:bg-muted/30" </Button>
> <div className="flex items-center space-x-1 text-xs sm:text-sm">
{row.getVisibleCells().map((cell) => ( <span>Page</span>
<TableCell key={cell.id} className="py-3 text-xs sm:text-sm"> <strong>{pageIndex + 1}</strong>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {canNext && <span>+</span>}
</TableCell> </div>
))} <Button
</TableRow> variant="outline"
)) size="sm"
) : ( onClick={() => onPageIndexChange?.(pageIndex + 1)}
<TableRow> disabled={!canNext}
<TableCell className="text-xs sm:text-sm"
colSpan={columns.length} >
className="h-32 text-center text-muted-foreground text-sm" Next
> </Button>
No documents found. </div>
</TableCell> </div>
</TableRow> </div>
)} );
</TableBody>
</Table>
</div>
</div>
{/* Footer Pagination */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
<div className="text-center sm:text-left">
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)}{" "}
of {table.getFilteredRowModel().rows.length} documents
</div>
<div className="flex items-center justify-center sm:justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="text-xs sm:text-sm"
>
Previous
</Button>
<div className="flex items-center space-x-1 text-xs sm:text-sm">
<span>Page</span>
<strong>{table.getState().pagination.pageIndex + 1}</strong>
<span>of</span>
<strong>{table.getPageCount()}</strong>
</div>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="text-xs sm:text-sm"
>
Next
</Button>
</div>
</div>
</div>
);
} }

View file

@ -29,19 +29,24 @@ export type DocumentType =
| "GOOGLE_GMAIL_CONNECTOR" | "GOOGLE_GMAIL_CONNECTOR"
| "AIRTABLE_CONNECTOR"; | "AIRTABLE_CONNECTOR";
export function useDocuments(searchSpaceId: number, lazy: boolean = false) { export function useDocuments(
searchSpaceId: number,
lazy: boolean = false,
skip: number = 0,
limit: number = 300
) {
const [documents, setDocuments] = useState<Document[]>([]); const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(!lazy); // Don't show loading initially for lazy mode const [loading, setLoading] = useState(!lazy); // Don't show loading initially for lazy mode
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false); // Memoization flag const [isLoaded, setIsLoaded] = useState(false); // Memoization flag
const fetchDocuments = useCallback(async () => { const fetchDocuments = useCallback(async () => {
if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
try { try {
setLoading(true); setLoading(true);
setError(null);
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}&skip=${skip}&limit=${limit}`,
{ {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
@ -51,27 +56,28 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
); );
if (!response.ok) { if (!response.ok) {
const errorText = await response.text();
toast.error("Failed to fetch documents"); toast.error("Failed to fetch documents");
throw new Error("Failed to fetch documents"); throw new Error(`Failed to fetch documents: ${response.status} ${errorText}`);
} }
const data = await response.json(); const data = await response.json();
setDocuments(data); setDocuments(data || []);
setError(null);
setIsLoaded(true); setIsLoaded(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to fetch documents"); setError(err.message || "Failed to fetch documents");
console.error("Error fetching documents:", err); console.error("Error fetching documents:", err);
setDocuments([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [searchSpaceId, isLoaded, lazy]); }, [searchSpaceId, skip, limit]);
useEffect(() => { useEffect(() => {
if (!lazy && searchSpaceId) { if (!lazy && searchSpaceId) {
fetchDocuments(); fetchDocuments();
} }
}, [searchSpaceId, lazy, fetchDocuments]); }, [searchSpaceId, skip, limit, lazy, fetchDocuments]);
// Function to refresh the documents list // Function to refresh the documents list
const refreshDocuments = useCallback(async () => { const refreshDocuments = useCallback(async () => {