refactor: optimize state management and filtering in AccessControl and ObjectsTable components

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-17 16:46:55 +02:00
parent 0baf31422a
commit 46f904fe59
3 changed files with 104 additions and 138 deletions
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -64,7 +64,6 @@ export function ObjectsTable({
const navigate = useNavigate(); const navigate = useNavigate();
const [sortColumn, setSortColumn] = useState<SortColumn>('name'); const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...] // Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]); const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
const [currentPageIndex, setCurrentPageIndex] = useState(0); const [currentPageIndex, setCurrentPageIndex] = useState(0);
@@ -86,14 +85,13 @@ export function ObjectsTable({
} }
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]); }, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
const sortObjects = (objList: S3Object[]): S3Object[] => { const filteredObjects = useMemo(() => {
const sorted = [...objList].sort((a, b) => { const query = searchQuery.toLowerCase();
// Always put folders before files const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
return [...filtered].sort((a, b) => {
const aIsFolder = a.isFolder ? 1 : 0; const aIsFolder = a.isFolder ? 1 : 0;
const bIsFolder = b.isFolder ? 1 : 0; const bIsFolder = b.isFolder ? 1 : 0;
if (aIsFolder !== bIsFolder) { if (aIsFolder !== bIsFolder) return bIsFolder - aIsFolder;
return bIsFolder - aIsFolder;
}
let compareValue = 0; let compareValue = 0;
switch (sortColumn) { switch (sortColumn) {
@@ -116,20 +114,7 @@ export function ObjectsTable({
return sortDirection === 'asc' ? compareValue : -compareValue; return sortDirection === 'asc' ? compareValue : -compareValue;
}); });
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
return sorted;
};
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
useEffect(() => {
const filtered = objects.filter((obj) =>
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
);
const sorted = sortObjects(filtered);
setFilteredObjects(sorted);
// Do NOT reset pagination - search/sort are client-side operations
}, [searchQuery, objects, sortColumn, sortDirection]);
// Effect 2: Reset pagination ONLY on path navigation // Effect 2: Reset pagination ONLY on path navigation
useEffect(() => { useEffect(() => {
@@ -192,6 +177,7 @@ export function ObjectsTable({
return ( return (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<TooltipProvider>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -304,55 +290,53 @@ export function ObjectsTable({
{obj.lastModified ? (() => { {obj.lastModified ? (() => {
const d = new Date(obj.lastModified); const d = new Date(obj.lastModified);
return ( return (
<TooltipProvider> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors"> {d.toLocaleDateString('en-GB', {
{d.toLocaleDateString('en-GB', { day: '2-digit',
day: '2-digit', month: 'short',
month: 'short', year: 'numeric',
year: 'numeric', })} {d.toLocaleTimeString('en-GB', {
})} {d.toLocaleTimeString('en-GB', { hour: '2-digit',
hour: '2-digit', minute: '2-digit',
minute: '2-digit', second: '2-digit',
second: '2-digit', hour12: false,
hour12: false, })} CET
})} CET </div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 min-w-max">
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
<span className="text-sm text-white">
{d.toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'UTC',
})} UTC
</span>
</div> </div>
</TooltipTrigger> <div className="flex gap-3 items-center">
<TooltipContent> <span className="text-sm text-gray-400 w-20 text-right">Relative</span>
<div className="space-y-1 min-w-max"> <span className="text-sm text-white">
<div className="flex gap-3 items-center"> {formatRelativeTime(d)}
<span className="text-sm text-gray-400 w-20 text-right">UTC</span> </span>
<span className="text-sm text-white">
{d.toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'UTC',
})} UTC
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
<span className="text-sm text-white">
{formatRelativeTime(d)}
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
<span className="text-sm text-white font-mono">
{d.toISOString()}
</span>
</div>
</div> </div>
</TooltipContent> <div className="flex gap-3 items-center">
</Tooltip> <span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
</TooltipProvider> <span className="text-sm text-white font-mono">
{d.toISOString()}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
); );
})() : null} })() : null}
</TableCell> </TableCell>
@@ -390,6 +374,7 @@ export function ObjectsTable({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TooltipProvider>
</div> </div>
{/* Pagination Controls */} {/* Pagination Controls */}
+40 -58
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { objectsApi } from '@/lib/api'; import { objectsApi } from '@/lib/api';
import type { S3Object, UploadTask } from '@/types'; import type { S3Object, UploadTask } from '@/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -13,8 +13,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined); const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
const [itemsPerPage, setItemsPerPage] = useState(25); const [itemsPerPage, setItemsPerPage] = useState(25);
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined); const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
const [previousPath, setPreviousPath] = useState<string>(currentPath); const previousPathRef = useRef<string>(currentPath);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]); const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => { const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
if (!bucketName) return; if (!bucketName) return;
@@ -46,24 +47,26 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
useEffect(() => { useEffect(() => {
if (!bucketName) return; if (!bucketName) return;
// Detect if this is a path change (navigation) or initial load const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
const isPathChange = previousPath !== currentPath && objects.length > 0; previousPathRef.current = currentPath;
setPreviousPath(currentPath);
// Use navigation mode if it's a path change, otherwise use normal loading
fetchObjects(undefined, false, isPathChange); fetchObjects(undefined, false, isPathChange);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucketName, currentPath, itemsPerPage]); }, [bucketName, currentPath, itemsPerPage]);
useEffect(() => {
return () => {
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
};
}, []);
const uploadFiles = useCallback(async (files: File[]) => { const uploadFiles = useCallback(async (files: File[]) => {
if (!bucketName) return false; if (!bucketName) return false;
// Check if files are from a folder upload const hasRelativePaths = files.some((file) => !!file.webkitRelativePath);
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
// Get unique folders from the files
const folders = new Set<string>(); const folders = new Set<string>();
files.forEach((file: any) => { files.forEach((file) => {
if (file.webkitRelativePath) { if (file.webkitRelativePath) {
const parts = file.webkitRelativePath.split('/'); const parts = file.webkitRelativePath.split('/');
if (parts.length > 1) { if (parts.length > 1) {
@@ -72,9 +75,8 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
} }
}); });
// Initialize upload tasks
const tasks: UploadTask[] = files.map((file, index) => { const tasks: UploadTask[] = files.map((file, index) => {
const relativePath = (file as any).webkitRelativePath || file.name; const relativePath = file.webkitRelativePath || file.name;
const key = currentPath ? `${currentPath}${relativePath}` : relativePath; const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
return { return {
id: `${Date.now()}-${index}`, id: `${Date.now()}-${index}`,
@@ -88,53 +90,36 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setUploadTasks(tasks); setUploadTasks(tasks);
// Upload files with progress tracking and error handling
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
// Upload files one by one for (const task of tasks) {
const concurrency = 1; try {
const uploadPromises: Promise<void>[] = []; setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'uploading' as const } : t
));
for (let i = 0; i < tasks.length; i += concurrency) { await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length)); setUploadTasks(prev => prev.map(t => {
if (t.id !== task.id || t.progress === progress) return t;
return { ...t, progress };
}));
});
const batchPromises = batch.map(async (task) => { setUploadTasks(prev => prev.map(t =>
try { t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
// Update task status to uploading ));
setUploadTasks(prev => prev.map(t => successCount++;
t.id === task.id ? { ...t, status: 'uploading' as const } : t } catch (error) {
)); const errorMessage = error instanceof Error ? error.message : 'Upload failed';
setUploadTasks(prev => prev.map(t =>
await objectsApi.upload(bucketName, task.key, task.file, (progress) => { t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
setUploadTasks(prev => prev.map(t => ));
t.id === task.id ? { ...t, progress } : t errorCount++;
)); console.error(`Failed to upload ${task.key}:`, error);
}); }
// Update task status to completed
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
));
successCount++;
} catch (error) {
// Update task status to error but continue with other uploads
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
));
errorCount++;
console.error(`Failed to upload ${task.key}:`, error);
}
});
uploadPromises.push(...batchPromises);
await Promise.all(batchPromises);
} }
await Promise.all(uploadPromises);
// Show summary toast
if (errorCount === 0) { if (errorCount === 0) {
if (hasRelativePaths && folders.size > 0) { if (hasRelativePaths && folders.size > 0) {
const folderNames = Array.from(folders).join(', '); const folderNames = Array.from(folders).join(', ');
@@ -148,9 +133,10 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`); toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
} }
// Clear upload tasks after a delay if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
setTimeout(() => { clearTasksTimerRef.current = setTimeout(() => {
setUploadTasks([]); setUploadTasks([]);
clearTasksTimerRef.current = null;
}, 3000); }, 3000);
await fetchObjects(currentContinuationToken, true); await fetchObjects(currentContinuationToken, true);
@@ -161,7 +147,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
if (!bucketName) return false; if (!bucketName) return false;
try { try {
// Optimistically remove the object from the UI
setObjects(prev => prev.filter(obj => obj.key !== key)); setObjects(prev => prev.filter(obj => obj.key !== key));
await objectsApi.delete(bucketName, key); await objectsApi.delete(bucketName, key);
@@ -170,7 +155,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
return true; return true;
} catch (error) { } catch (error) {
console.error('Delete object error:', error); console.error('Delete object error:', error);
// Revert the optimistic update by refetching
await fetchObjects(currentContinuationToken, true); await fetchObjects(currentContinuationToken, true);
return false; return false;
} }
@@ -180,7 +164,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
if (!bucketName || keys.length === 0) return false; if (!bucketName || keys.length === 0) return false;
try { try {
// Optimistically remove the objects from the UI
setObjects(prev => prev.filter(obj => !keys.includes(obj.key))); setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined); await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
@@ -189,7 +172,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
return true; return true;
} catch (error) { } catch (error) {
console.error('Bulk delete error:', error); console.error('Bulk delete error:', error);
// Revert the optimistic update by refetching
await fetchObjects(currentContinuationToken, true); await fetchObjects(currentContinuationToken, true);
return false; return false;
} }
+10 -11
View File
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {Header} from '@/components/layout/header'; import {Header} from '@/components/layout/header';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
@@ -31,7 +31,6 @@ import {toast} from 'sonner';
export function AccessControl() { export function AccessControl() {
const [keys, setKeys] = useState<AccessKey[]>([]); const [keys, setKeys] = useState<AccessKey[]>([]);
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -83,7 +82,6 @@ export function AccessControl() {
setIsLoading(true); setIsLoading(true);
const data = await accessApi.listKeys(); const data = await accessApi.listKeys();
setKeys(data); setKeys(data);
setFilteredKeys(data);
} catch (error) { } catch (error) {
console.error('Failed to fetch keys:', error); console.error('Failed to fetch keys:', error);
} finally { } finally {
@@ -94,14 +92,15 @@ export function AccessControl() {
fetchKeys(); fetchKeys();
}, []); }, []);
useEffect(() => { const filteredKeys = useMemo(
const filtered = keys.filter( () =>
(key) => keys.filter(
key.name.toLowerCase().includes(searchQuery.toLowerCase()) || (key) =>
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase()) key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
); key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
setFilteredKeys(filtered); ),
}, [searchQuery, keys]); [keys, searchQuery]
);
const handleCreateKey = async () => { const handleCreateKey = async () => {
if (!newKeyName) { if (!newKeyName) {