Feat: improve file upload error handling for unspported files

This commit is contained in:
yangdx
2025-03-28 14:16:53 +08:00
parent 783020aab5
commit 34c92e1045
2 changed files with 222 additions and 66 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { FileRejection } from 'react-dropzone'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { import {
Dialog, Dialog,
@@ -23,18 +24,63 @@ export default function UploadDocumentsDialog() {
const [progresses, setProgresses] = useState<Record<string, number>>({}) const [progresses, setProgresses] = useState<Record<string, number>>({})
const [fileErrors, setFileErrors] = useState<Record<string, string>>({}) const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
const handleRejectedFiles = useCallback(
(rejectedFiles: FileRejection[]) => {
// Process rejected files and add them to fileErrors
rejectedFiles.forEach(({ file, errors }) => {
// Get the first error message
let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })
// Simplify error message for unsupported file types
if (errorMsg.includes('file-invalid-type')) {
errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
// Add error message to fileErrors
setFileErrors(prev => ({
...prev,
[file.name]: errorMsg
}))
})
},
[setProgresses, setFileErrors, t]
)
const handleDocumentsUpload = useCallback( const handleDocumentsUpload = useCallback(
async (filesToUpload: File[]) => { async (filesToUpload: File[]) => {
setIsUploading(true) setIsUploading(true)
setFileErrors({})
// Only clear errors for files that are being uploaded, keep errors for rejected files
setFileErrors(prev => {
const newErrors = { ...prev };
filesToUpload.forEach(file => {
delete newErrors[file.name];
});
return newErrors;
});
// Show uploading toast
const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading'))
try { try {
toast.promise( // Track errors locally to ensure we have the final state
(async () => { const uploadErrors: Record<string, string> = {}
try {
await Promise.all( await Promise.all(
filesToUpload.map(async (file) => { filesToUpload.map(async (file) => {
try { try {
// Initialize upload progress
setProgresses((pre) => ({
...pre,
[file.name]: 0
}))
const result = await uploadDocument(file, (percentCompleted: number) => { const result = await uploadDocument(file, (percentCompleted: number) => {
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted })) console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
setProgresses((pre) => ({ setProgresses((pre) => ({
@@ -44,36 +90,60 @@ export default function UploadDocumentsDialog() {
}) })
if (result.status !== 'success') { if (result.status !== 'success') {
uploadErrors[file.name] = result.message
setFileErrors(prev => ({ setFileErrors(prev => ({
...prev, ...prev,
[file.name]: result.message [file.name]: result.message
})) }))
} }
} catch (err) { } catch (err) {
console.error(`Upload failed for ${file.name}:`, err)
// Handle HTTP errors, including 400 errors
let errorMsg = errorMessage(err)
// If it's an axios error with response data, try to extract more detailed error info
if (err && typeof err === 'object' && 'response' in err) {
const axiosError = err as { response?: { status: number, data?: { detail?: string } } }
if (axiosError.response?.status === 400) {
// Extract specific error message from backend response
errorMsg = axiosError.response.data?.detail || errorMsg
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
}
// Record error message in both local tracking and state
uploadErrors[file.name] = errorMsg
setFileErrors(prev => ({ setFileErrors(prev => ({
...prev, ...prev,
[file.name]: errorMessage(err) [file.name]: errorMsg
})) }))
} }
}) })
) )
} catch (error) {
console.error('Upload failed:', error) // Check if any files failed to upload using our local tracking
const hasErrors = Object.keys(uploadErrors).length > 0
// Update toast status
if (hasErrors) {
toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId })
} else {
toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId })
} }
})(),
{
loading: t('documentPanel.uploadDocuments.batch.uploading'),
success: t('documentPanel.uploadDocuments.batch.success'),
error: t('documentPanel.uploadDocuments.batch.error')
}
)
} catch (err) { } catch (err) {
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) })) console.error('Unexpected error during upload:', err)
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId })
} finally { } finally {
setIsUploading(false) setIsUploading(false)
} }
}, },
[setIsUploading, setProgresses, t] [setIsUploading, setProgresses, setFileErrors, t]
) )
return ( return (
@@ -107,6 +177,7 @@ export default function UploadDocumentsDialog() {
maxSize={200 * 1024 * 1024} maxSize={200 * 1024 * 1024}
description={t('documentPanel.uploadDocuments.fileTypes')} description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload} onUpload={handleDocumentsUpload}
onReject={handleRejectedFiles}
progresses={progresses} progresses={progresses}
fileErrors={fileErrors} fileErrors={fileErrors}
disabled={isUploading} disabled={isUploading}

View File

@@ -39,6 +39,14 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
*/ */
onUpload?: (files: File[]) => Promise<void> onUpload?: (files: File[]) => Promise<void>
/**
* Function to be called when files are rejected.
* @type (rejections: FileRejection[]) => void
* @default undefined
* @example onReject={(rejections) => handleRejectedFiles(rejections)}
*/
onReject?: (rejections: FileRejection[]) => void
/** /**
* Progress of the uploaded files. * Progress of the uploaded files.
* @type Record<string, number> | undefined * @type Record<string, number> | undefined
@@ -125,6 +133,7 @@ function FileUploader(props: FileUploaderProps) {
value: valueProp, value: valueProp,
onValueChange, onValueChange,
onUpload, onUpload,
onReject,
progresses, progresses,
fileErrors, fileErrors,
accept = supportedFileTypes, accept = supportedFileTypes,
@@ -144,38 +153,77 @@ function FileUploader(props: FileUploaderProps) {
const onDrop = React.useCallback( const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => { (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) { // Calculate total file count including both accepted and rejected files
const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length
// Check file count limits
if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit')) toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))
return return
} }
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) { if (totalFileCount > maxFileCount) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount })) toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
return return
} }
const newFiles = acceptedFiles.map((file) => // Handle rejected files first - this will set error states
if (rejectedFiles.length > 0) {
if (onReject) {
// Use the onReject callback if provided
onReject(rejectedFiles)
} else {
// Fall back to toast notifications if no callback is provided
rejectedFiles.forEach(({ file }) => {
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
})
}
}
// Process accepted files
const newAcceptedFiles = acceptedFiles.map((file) =>
Object.assign(file, { Object.assign(file, {
preview: URL.createObjectURL(file) preview: URL.createObjectURL(file)
}) })
) )
const updatedFiles = files ? [...files, ...newFiles] : newFiles // Process rejected files for UI display
const newRejectedFiles = rejectedFiles.map(({ file }) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
rejected: true
})
)
// Combine all files for display
const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles
// Update the files state with all files
setFiles(updatedFiles) setFiles(updatedFiles)
if (rejectedFiles.length > 0) { // Only upload accepted files - make sure we're not uploading rejected files
rejectedFiles.forEach(({ file }) => { if (onUpload && acceptedFiles.length > 0) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })) // Filter out any files that might have been rejected by our custom validator
}) const validFiles = acceptedFiles.filter(file => {
} // Check if file type is accepted
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
return file.type === mimeType || extensions.includes(fileExt);
});
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) { // Check file size
onUpload(updatedFiles) const isSizeValid = file.size <= maxSize;
return isAccepted && isSizeValid;
});
if (validFiles.length > 0) {
onUpload(validFiles);
}
} }
}, },
[files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
[files, maxFileCount, multiple, onUpload, setFiles, t]
) )
function onRemove(index: number) { function onRemove(index: number) {
@@ -204,11 +252,39 @@ function FileUploader(props: FileUploaderProps) {
<div className="relative flex flex-col gap-6 overflow-hidden"> <div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone <Dropzone
onDrop={onDrop} onDrop={onDrop}
accept={accept} // remove acceptuse customizd validator
noClick={false}
noKeyboard={false}
maxSize={maxSize} maxSize={maxSize}
maxFiles={maxFileCount} maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple} multiple={maxFileCount > 1 || multiple}
disabled={isDisabled} disabled={isDisabled}
validator={(file) => {
// Check if file type is accepted
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
return file.type === mimeType || extensions.includes(fileExt);
});
if (!isAccepted) {
return {
code: 'file-invalid-type',
message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
};
}
// Check file size
if (file.size > maxSize) {
return {
code: 'file-too-large',
message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', {
maxSize: formatBytes(maxSize)
})
};
}
return null;
}}
> >
{({ getRootProps, getInputProps, isDragActive }) => ( {({ getRootProps, getInputProps, isDragActive }) => (
<div <div
@@ -279,11 +355,13 @@ function FileUploader(props: FileUploaderProps) {
interface ProgressProps { interface ProgressProps {
value: number value: number
error?: boolean error?: boolean
showIcon?: boolean // New property to control icon display
} }
function Progress({ value, error }: ProgressProps) { function Progress({ value, error }: ProgressProps) {
return ( return (
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary"> <div className="relative h-2 w-full">
<div className="h-full w-full overflow-hidden rounded-full bg-secondary">
<div <div
className={cn( className={cn(
'h-full transition-all', 'h-full transition-all',
@@ -292,6 +370,7 @@ function Progress({ value, error }: ProgressProps) {
style={{ width: `${value}%` }} style={{ width: `${value}%` }}
/> />
</div> </div>
</div>
) )
} }
@@ -307,7 +386,11 @@ 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">
{isFileWithPreview(file) ? <FilePreview file={file} /> : null} {error ? (
<FileText className="text-destructive size-10" aria-hidden="true" />
) : (
isFileWithPreview(file) ? <FilePreview file={file} /> : null
)}
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
<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>
@@ -315,8 +398,10 @@ function FileCard({ file, progress, error, onRemove }: FileCardProps) {
</div> </div>
{error ? ( {error ? (
<div className="text-destructive text-sm"> <div className="text-destructive text-sm">
<div className="relative mb-2">
<Progress value={100} error={true} /> <Progress value={100} error={true} />
<p className="mt-1">{error}</p> </div>
<p>{error}</p>
</div> </div>
) : ( ) : (
progress ? <Progress value={progress} /> : null progress ? <Progress value={progress} /> : null