Sync RecentRequestsTable with URL and debounce

Refactor RecentRequestsTable to manage filters client-side and sync them to the browser URL. Removed next/navigation hooks (useRouter, usePathname, useSearchParams) and added getInitialParams + local useState for page, pageSize, status, userId, sortBy, sortOrder and a debouncedSearch. Added keepPreviousData to SWR to avoid layout shifts, implemented history.replaceState to shallow-sync URL when filters change, and a popstate handler to support back/forward navigation. Consolidated filter updates via updateFilter, added page reset behavior on filter/search changes, and improved search debouncing logic. Also removed Suspense usage and import around RecentRequestsTable in admin/page.tsx.
This commit is contained in:
kikootwo
2026-02-02 12:13:51 -05:00
parent 770cd5165f
commit 0864fa7b43
2 changed files with 154 additions and 70 deletions
+150 -56
View File
@@ -5,8 +5,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import useSWR from 'swr'; import useSWR from 'swr';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
@@ -125,23 +124,56 @@ function SortIcon({ field, currentSort, currentOrder }: { field: SortField; curr
); );
} }
// Helper to get initial params from URL (client-side only)
function getInitialParams(): {
page: number;
pageSize: number;
search: string;
status: string;
userId: string;
sortBy: SortField;
sortOrder: SortOrder;
} {
if (typeof window === 'undefined') {
return {
page: 1,
pageSize: 25,
search: '',
status: 'all',
userId: '',
sortBy: 'createdAt',
sortOrder: 'desc',
};
}
const params = new URLSearchParams(window.location.search);
return {
page: parseInt(params.get('page') || '1', 10),
pageSize: parseInt(params.get('pageSize') || '25', 10),
search: params.get('search') || '',
status: params.get('status') || 'all',
userId: params.get('userId') || '',
sortBy: (params.get('sortBy') || 'createdAt') as SortField,
sortOrder: (params.get('sortOrder') || 'desc') as SortOrder,
};
}
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) { export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const toast = useToast(); const toast = useToast();
// Get filter state from URL // Get initial filter state from URL (only evaluated once due to lazy init)
const page = parseInt(searchParams.get('page') || '1', 10); const [initialParams] = useState(getInitialParams);
const pageSize = parseInt(searchParams.get('pageSize') || '25', 10); const [page, setPage] = useState(initialParams.page);
const search = searchParams.get('search') || ''; const [pageSize, setPageSize] = useState(initialParams.pageSize);
const status = searchParams.get('status') || 'all'; const [searchInput, setSearchInput] = useState(initialParams.search);
const userId = searchParams.get('userId') || ''; const [debouncedSearch, setDebouncedSearch] = useState(initialParams.search);
const sortBy = (searchParams.get('sortBy') || 'createdAt') as SortField; const [status, setStatus] = useState(initialParams.status);
const sortOrder = (searchParams.get('sortOrder') || 'desc') as SortOrder; const [userId, setUserId] = useState(initialParams.userId);
const [sortBy, setSortBy] = useState<SortField>(initialParams.sortBy);
const [sortOrder, setSortOrder] = useState<SortOrder>(initialParams.sortOrder);
// Local search input state for debouncing // Track mounted state and last synced URL to handle browser back/forward
const [searchInput, setSearchInput] = useState(search); const isMounted = useRef(false);
const lastSyncedUrl = useRef('');
// Dialog states // Dialog states
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -152,71 +184,133 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false); const [isFetchingEbook, setIsFetchingEbook] = useState(false);
// Build API URL with current filters // Build API URL with current local filters
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(search)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`; const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
// Fetch requests with SWR // Fetch requests with SWR
const { data, error, isLoading } = useSWR<RequestsResponse>(apiUrl, authenticatedFetcher, { const { data, error, isLoading } = useSWR<RequestsResponse>(apiUrl, authenticatedFetcher, {
refreshInterval: 10000, refreshInterval: 10000,
keepPreviousData: true, // Keep showing old data while fetching new data to prevent layout shifts
}); });
// Fetch users for filter dropdown // Fetch users for filter dropdown
const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher); const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher);
// Update URL with new params // Build URL string for syncing
const updateParams = useCallback( const buildUrlString = useCallback((params: {
(updates: Record<string, string | number>) => { page: number;
const params = new URLSearchParams(searchParams.toString()); pageSize: number;
search: string;
status: string;
userId: string;
sortBy: string;
sortOrder: string;
}) => {
const pathname = typeof window !== 'undefined' ? window.location.pathname : '/admin';
const urlParams = new URLSearchParams();
if (params.page !== 1) urlParams.set('page', String(params.page));
if (params.pageSize !== 25) urlParams.set('pageSize', String(params.pageSize));
if (params.search) urlParams.set('search', params.search);
if (params.status !== 'all') urlParams.set('status', params.status);
if (params.userId) urlParams.set('userId', params.userId);
if (params.sortBy !== 'createdAt') urlParams.set('sortBy', params.sortBy);
if (params.sortOrder !== 'desc') urlParams.set('sortOrder', params.sortOrder);
return urlParams.toString() ? `${pathname}?${urlParams.toString()}` : pathname;
}, []);
Object.entries(updates).forEach(([key, value]) => { // Sync URL when filters change (shallow, doesn't cause re-render)
if (value === '' || value === 'all' || (key === 'page' && value === 1) || (key === 'pageSize' && value === 25)) { useEffect(() => {
params.delete(key); if (!isMounted.current) {
} else { isMounted.current = true;
params.set(key, String(value)); return;
} }
});
// Reset to page 1 when filters change (except when changing page itself) const newUrl = buildUrlString({
if (!('page' in updates)) { page,
params.delete('page'); pageSize,
} search: debouncedSearch,
status,
userId,
sortBy,
sortOrder,
});
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; if (newUrl !== lastSyncedUrl.current && typeof window !== 'undefined') {
router.push(newUrl, { scroll: false }); lastSyncedUrl.current = newUrl;
}, window.history.replaceState(null, '', newUrl);
[pathname, router, searchParams] }
); }, [page, pageSize, debouncedSearch, status, userId, sortBy, sortOrder, buildUrlString]);
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
const params = new URLSearchParams(window.location.search);
setPage(parseInt(params.get('page') || '1', 10));
setPageSize(parseInt(params.get('pageSize') || '25', 10));
const newSearch = params.get('search') || '';
setSearchInput(newSearch);
setDebouncedSearch(newSearch);
setStatus(params.get('status') || 'all');
setUserId(params.get('userId') || '');
setSortBy((params.get('sortBy') || 'createdAt') as SortField);
setSortOrder((params.get('sortOrder') || 'desc') as SortOrder);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Debounce search input // Debounce search input
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (searchInput !== search) { if (searchInput !== debouncedSearch) {
updateParams({ search: searchInput }); setDebouncedSearch(searchInput);
setPage(1); // Reset to page 1 on search change
} }
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchInput, search, updateParams]); }, [searchInput, debouncedSearch]);
// Sync search input with URL param on mount // Helper to update filters and reset page
useEffect(() => { const updateFilter = useCallback((key: string, value: string | number) => {
setSearchInput(search); switch (key) {
}, [search]); case 'status':
setStatus(value as string);
setPage(1);
break;
case 'userId':
setUserId(value as string);
setPage(1);
break;
case 'pageSize':
setPageSize(value as number);
setPage(1);
break;
case 'page':
setPage(value as number);
break;
}
}, []);
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (field === sortBy) { if (field === sortBy) {
updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' }); setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else { } else {
updateParams({ sortBy: field, sortOrder: 'desc' }); setSortBy(field);
setSortOrder('desc');
} }
}; };
const clearFilters = () => { const clearFilters = () => {
setSearchInput(''); setSearchInput('');
router.push(pathname, { scroll: false }); setDebouncedSearch('');
setStatus('all');
setUserId('');
setPage(1);
}; };
const hasActiveFilters = search || status !== 'all' || userId; const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
// Action handlers // Action handlers
const handleDeleteClick = (requestId: string, title: string) => { const handleDeleteClick = (requestId: string, title: string) => {
@@ -391,7 +485,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
{/* Status Filter */} {/* Status Filter */}
<select <select
value={status} value={status}
onChange={(e) => updateParams({ status: e.target.value })} onChange={(e) => updateFilter('status', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]" className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
> >
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
@@ -404,7 +498,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
{/* User Filter */} {/* User Filter */}
<select <select
value={userId} value={userId}
onChange={(e) => updateParams({ userId: e.target.value })} onChange={(e) => updateFilter('userId', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]" className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
> >
<option value="">All Users</option> <option value="">All Users</option>
@@ -582,7 +676,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
<label className="text-sm text-gray-700 dark:text-gray-300">Show:</label> <label className="text-sm text-gray-700 dark:text-gray-300">Show:</label>
<select <select
value={pageSize} value={pageSize}
onChange={(e) => updateParams({ pageSize: parseInt(e.target.value, 10) })} onChange={(e) => updateFilter('pageSize', parseInt(e.target.value, 10))}
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
> >
{PAGE_SIZE_OPTIONS.map((size) => ( {PAGE_SIZE_OPTIONS.map((size) => (
@@ -596,7 +690,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
{/* Page navigation */} {/* Page navigation */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => updateParams({ page: 1 })} onClick={() => setPage(1)}
disabled={page === 1} disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="First page" title="First page"
@@ -606,7 +700,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
</svg> </svg>
</button> </button>
<button <button
onClick={() => updateParams({ page: page - 1 })} onClick={() => setPage(page - 1)}
disabled={page === 1} disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Previous page" title="Previous page"
@@ -632,7 +726,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
return ( return (
<button <button
key={pageNum} key={pageNum}
onClick={() => updateParams({ page: pageNum })} onClick={() => setPage(pageNum)}
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors ${ className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors ${
page === pageNum page === pageNum
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
@@ -646,7 +740,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
</div> </div>
<button <button
onClick={() => updateParams({ page: page + 1 })} onClick={() => setPage(page + 1)}
disabled={page === totalPages} disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Next page" title="Next page"
@@ -656,7 +750,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
</svg> </svg>
</button> </button>
<button <button
onClick={() => updateParams({ page: totalPages })} onClick={() => setPage(totalPages)}
disabled={page === totalPages} disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Last page" title="Last page"
+4 -14
View File
@@ -13,7 +13,7 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState, Suspense } from 'react'; import { useState } from 'react';
interface PendingApprovalRequest { interface PendingApprovalRequest {
id: string; id: string;
@@ -488,19 +488,9 @@ function AdminDashboardContent() {
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Request Management Request Management
</h2> </h2>
<Suspense <RecentRequestsTable
fallback={ ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8"> />
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
}
>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
/>
</Suspense>
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}