Feat: improve file upload error handling for unspported files
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { FileRejection } from 'react-dropzone'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,57 +24,126 @@ export default function UploadDocumentsDialog() {
|
||||
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
||||
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(
|
||||
async (filesToUpload: File[]) => {
|
||||
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 {
|
||||
toast.promise(
|
||||
(async () => {
|
||||
// Track errors locally to ensure we have the final state
|
||||
const uploadErrors: Record<string, string> = {}
|
||||
|
||||
await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
try {
|
||||
const result = await uploadDocument(file, (percentCompleted: number) => {
|
||||
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: percentCompleted
|
||||
}))
|
||||
})
|
||||
// Initialize upload progress
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: 0
|
||||
}))
|
||||
|
||||
const result = await uploadDocument(file, (percentCompleted: number) => {
|
||||
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: percentCompleted
|
||||
}))
|
||||
})
|
||||
|
||||
if (result.status !== 'success') {
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: result.message
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: errorMessage(err)
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
if (result.status !== 'success') {
|
||||
uploadErrors[file.name] = result.message
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: result.message
|
||||
}))
|
||||
}
|
||||
} 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 => ({
|
||||
...prev,
|
||||
[file.name]: errorMsg
|
||||
}))
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: t('documentPanel.uploadDocuments.batch.uploading'),
|
||||
success: t('documentPanel.uploadDocuments.batch.success'),
|
||||
error: t('documentPanel.uploadDocuments.batch.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 })
|
||||
}
|
||||
} 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 {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[setIsUploading, setProgresses, t]
|
||||
[setIsUploading, setProgresses, setFileErrors, t]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -107,6 +177,7 @@ export default function UploadDocumentsDialog() {
|
||||
maxSize={200 * 1024 * 1024}
|
||||
description={t('documentPanel.uploadDocuments.fileTypes')}
|
||||
onUpload={handleDocumentsUpload}
|
||||
onReject={handleRejectedFiles}
|
||||
progresses={progresses}
|
||||
fileErrors={fileErrors}
|
||||
disabled={isUploading}
|
||||
|
@@ -39,6 +39,14 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
*/
|
||||
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.
|
||||
* @type Record<string, number> | undefined
|
||||
@@ -125,6 +133,7 @@ function FileUploader(props: FileUploaderProps) {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
onReject,
|
||||
progresses,
|
||||
fileErrors,
|
||||
accept = supportedFileTypes,
|
||||
@@ -144,38 +153,77 @@ function FileUploader(props: FileUploaderProps) {
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(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'))
|
||||
return
|
||||
}
|
||||
|
||||
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
||||
if (totalFileCount > maxFileCount) {
|
||||
toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
|
||||
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, {
|
||||
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)
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
|
||||
})
|
||||
}
|
||||
|
||||
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
|
||||
onUpload(updatedFiles)
|
||||
// Only upload accepted files - make sure we're not uploading rejected files
|
||||
if (onUpload && acceptedFiles.length > 0) {
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Check file size
|
||||
const isSizeValid = file.size <= maxSize;
|
||||
|
||||
return isAccepted && isSizeValid;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onUpload(validFiles);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[files, maxFileCount, multiple, onUpload, setFiles, t]
|
||||
[files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
|
||||
)
|
||||
|
||||
function onRemove(index: number) {
|
||||
@@ -204,11 +252,39 @@ function FileUploader(props: FileUploaderProps) {
|
||||
<div className="relative flex flex-col gap-6 overflow-hidden">
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
accept={accept}
|
||||
// remove accept,use customizd validator
|
||||
noClick={false}
|
||||
noKeyboard={false}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFileCount}
|
||||
multiple={maxFileCount > 1 || multiple}
|
||||
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 }) => (
|
||||
<div
|
||||
@@ -279,18 +355,21 @@ function FileUploader(props: FileUploaderProps) {
|
||||
interface ProgressProps {
|
||||
value: number
|
||||
error?: boolean
|
||||
showIcon?: boolean // New property to control icon display
|
||||
}
|
||||
|
||||
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 className="relative h-2 w-full">
|
||||
<div className="h-full w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all',
|
||||
error ? 'bg-destructive' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -307,7 +386,11 @@ 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">
|
||||
{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 flex-col gap-px">
|
||||
<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>
|
||||
{error ? (
|
||||
<div className="text-destructive text-sm">
|
||||
<Progress value={100} error={true} />
|
||||
<p className="mt-1">{error}</p>
|
||||
<div className="relative mb-2">
|
||||
<Progress value={100} error={true} />
|
||||
</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
progress ? <Progress value={progress} /> : null
|
||||
|
Reference in New Issue
Block a user