feat(upload): improve file upload progress and error handling
- Add persistent progress bars and error states - Remove individual file toasts in favor of batch status - Keep dialog open until manual close - Move Progress component inline to reduce dependencies
This commit is contained in:
@@ -21,11 +21,20 @@ export default function UploadDocumentsDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
||||
// Track upload errors for each file
|
||||
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const handleDocumentsUpload = useCallback(
|
||||
async (filesToUpload: File[]) => {
|
||||
setIsUploading(true)
|
||||
|
||||
// Reset error states before new upload
|
||||
setFileErrors({})
|
||||
|
||||
try {
|
||||
// Use a single toast for the entire batch upload process
|
||||
toast.promise(
|
||||
(async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
@@ -37,21 +46,40 @@ export default function UploadDocumentsDialog() {
|
||||
[file.name]: percentCompleted
|
||||
}))
|
||||
})
|
||||
if (result.status === 'success') {
|
||||
toast.success(t('documentPanel.uploadDocuments.success', { name: file.name }))
|
||||
} else {
|
||||
toast.error(t('documentPanel.uploadDocuments.failed', { name: file.name, message: result.message }))
|
||||
|
||||
// Store error message if upload failed
|
||||
if (result.status !== 'success') {
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: result.message
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.uploadDocuments.error', { name: file.name, error: errorMessage(err) }))
|
||||
// Store error message from exception
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: errorMessage(err)
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
// Keep dialog open to show final status
|
||||
// User needs to close dialog manually
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: t('documentPanel.uploadDocuments.uploading.batch'),
|
||||
success: t('documentPanel.uploadDocuments.success.batch'),
|
||||
error: t('documentPanel.uploadDocuments.error.batch')
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }))
|
||||
// Handle general upload errors
|
||||
toast.error(`Upload error: ${errorMessage(err)}`)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
// setOpen(false)
|
||||
}
|
||||
},
|
||||
[setIsUploading, setProgresses, t]
|
||||
@@ -61,9 +89,15 @@ export default function UploadDocumentsDialog() {
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (isUploading && !open) {
|
||||
// Prevent closing dialog during upload
|
||||
if (isUploading) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
// Reset states when dialog is closed
|
||||
setProgresses({})
|
||||
setFileErrors({})
|
||||
}
|
||||
setOpen(open)
|
||||
}}
|
||||
>
|
||||
@@ -85,6 +119,7 @@ export default function UploadDocumentsDialog() {
|
||||
description={t('documentPanel.uploadDocuments.fileTypes')}
|
||||
onUpload={handleDocumentsUpload}
|
||||
progresses={progresses}
|
||||
fileErrors={fileErrors}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
@@ -10,7 +10,6 @@ import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Progress from '@/components/ui/Progress'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { supportedFileTypes } from '@/lib/constants'
|
||||
|
||||
@@ -47,6 +46,14 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
*/
|
||||
progresses?: Record<string, number>
|
||||
|
||||
/**
|
||||
* Error messages for failed uploads.
|
||||
* @type Record<string, string> | undefined
|
||||
* @default undefined
|
||||
* @example fileErrors={{ "file1.png": "Upload failed" }}
|
||||
*/
|
||||
fileErrors?: Record<string, string>
|
||||
|
||||
/**
|
||||
* Accepted file types for the uploader.
|
||||
* @type { [key: string]: string[]}
|
||||
@@ -117,6 +124,7 @@ function FileUploader(props: FileUploaderProps) {
|
||||
onValueChange,
|
||||
onUpload,
|
||||
progresses,
|
||||
fileErrors,
|
||||
accept = supportedFileTypes,
|
||||
maxSize = 1024 * 1024 * 200,
|
||||
maxFileCount = 1,
|
||||
@@ -161,16 +169,7 @@ function FileUploader(props: FileUploaderProps) {
|
||||
}
|
||||
|
||||
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
|
||||
const target = updatedFiles.length > 0 ? `${updatedFiles.length} files` : 'file'
|
||||
|
||||
toast.promise(onUpload(updatedFiles), {
|
||||
loading: `Uploading ${target}...`,
|
||||
success: () => {
|
||||
setFiles([])
|
||||
return `${target} uploaded`
|
||||
},
|
||||
error: `Failed to upload ${target}`
|
||||
})
|
||||
onUpload(updatedFiles)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -265,6 +264,7 @@ function FileUploader(props: FileUploaderProps) {
|
||||
file={file}
|
||||
onRemove={() => onRemove(index)}
|
||||
progress={progresses?.[file.name]}
|
||||
error={fileErrors?.[file.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -274,13 +274,33 @@ function FileUploader(props: FileUploaderProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface ProgressProps {
|
||||
value: number
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
function Progress({ value, error }: ProgressProps) {
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all',
|
||||
error ? 'bg-destructive' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File
|
||||
onRemove: () => void
|
||||
progress?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
function FileCard({ file, progress, error, onRemove }: FileCardProps) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-2.5">
|
||||
<div className="flex flex-1 gap-2.5">
|
||||
@@ -290,7 +310,14 @@ function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
<p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
|
||||
</div>
|
||||
{progress ? <Progress value={progress} /> : null}
|
||||
{error ? (
|
||||
<div className="text-destructive text-sm">
|
||||
<Progress value={100} error={true} />
|
||||
<p className="mt-1">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
progress ? <Progress value={progress} /> : null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
Reference in New Issue
Block a user