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 [open, setOpen] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
||||||
|
// Track upload errors for each file
|
||||||
|
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const handleDocumentsUpload = useCallback(
|
const handleDocumentsUpload = useCallback(
|
||||||
async (filesToUpload: File[]) => {
|
async (filesToUpload: File[]) => {
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
|
|
||||||
|
// Reset error states before new upload
|
||||||
|
setFileErrors({})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a single toast for the entire batch upload process
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
filesToUpload.map(async (file) => {
|
filesToUpload.map(async (file) => {
|
||||||
@@ -37,21 +46,40 @@ export default function UploadDocumentsDialog() {
|
|||||||
[file.name]: percentCompleted
|
[file.name]: percentCompleted
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
if (result.status === 'success') {
|
|
||||||
toast.success(t('documentPanel.uploadDocuments.success', { name: file.name }))
|
// Store error message if upload failed
|
||||||
} else {
|
if (result.status !== 'success') {
|
||||||
toast.error(t('documentPanel.uploadDocuments.failed', { name: file.name, message: result.message }))
|
setFileErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: result.message
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
} catch (err) {
|
||||||
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }))
|
// Handle general upload errors
|
||||||
|
toast.error(`Upload error: ${errorMessage(err)}`)
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
// setOpen(false)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setIsUploading, setProgresses, t]
|
[setIsUploading, setProgresses, t]
|
||||||
@@ -61,9 +89,15 @@ export default function UploadDocumentsDialog() {
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (isUploading && !open) {
|
// Prevent closing dialog during upload
|
||||||
|
if (isUploading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!open) {
|
||||||
|
// Reset states when dialog is closed
|
||||||
|
setProgresses({})
|
||||||
|
setFileErrors({})
|
||||||
|
}
|
||||||
setOpen(open)
|
setOpen(open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -85,6 +119,7 @@ export default function UploadDocumentsDialog() {
|
|||||||
description={t('documentPanel.uploadDocuments.fileTypes')}
|
description={t('documentPanel.uploadDocuments.fileTypes')}
|
||||||
onUpload={handleDocumentsUpload}
|
onUpload={handleDocumentsUpload}
|
||||||
progresses={progresses}
|
progresses={progresses}
|
||||||
|
fileErrors={fileErrors}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
@@ -10,7 +10,6 @@ import { toast } from 'sonner'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import Progress from '@/components/ui/Progress'
|
|
||||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
import { supportedFileTypes } from '@/lib/constants'
|
import { supportedFileTypes } from '@/lib/constants'
|
||||||
|
|
||||||
@@ -47,6 +46,14 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
*/
|
*/
|
||||||
progresses?: Record<string, number>
|
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.
|
* Accepted file types for the uploader.
|
||||||
* @type { [key: string]: string[]}
|
* @type { [key: string]: string[]}
|
||||||
@@ -117,6 +124,7 @@ function FileUploader(props: FileUploaderProps) {
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
onUpload,
|
onUpload,
|
||||||
progresses,
|
progresses,
|
||||||
|
fileErrors,
|
||||||
accept = supportedFileTypes,
|
accept = supportedFileTypes,
|
||||||
maxSize = 1024 * 1024 * 200,
|
maxSize = 1024 * 1024 * 200,
|
||||||
maxFileCount = 1,
|
maxFileCount = 1,
|
||||||
@@ -161,16 +169,7 @@ function FileUploader(props: FileUploaderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
|
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
|
||||||
const target = updatedFiles.length > 0 ? `${updatedFiles.length} files` : 'file'
|
onUpload(updatedFiles)
|
||||||
|
|
||||||
toast.promise(onUpload(updatedFiles), {
|
|
||||||
loading: `Uploading ${target}...`,
|
|
||||||
success: () => {
|
|
||||||
setFiles([])
|
|
||||||
return `${target} uploaded`
|
|
||||||
},
|
|
||||||
error: `Failed to upload ${target}`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -265,6 +264,7 @@ function FileUploader(props: FileUploaderProps) {
|
|||||||
file={file}
|
file={file}
|
||||||
onRemove={() => onRemove(index)}
|
onRemove={() => onRemove(index)}
|
||||||
progress={progresses?.[file.name]}
|
progress={progresses?.[file.name]}
|
||||||
|
error={fileErrors?.[file.name]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 {
|
interface FileCardProps {
|
||||||
file: File
|
file: File
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
progress?: number
|
progress?: number
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
function FileCard({ file, progress, error, onRemove }: FileCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-2.5">
|
<div className="relative flex items-center gap-2.5">
|
||||||
<div className="flex flex-1 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-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
|
||||||
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
|
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
Reference in New Issue
Block a user