diff --git a/packages/file-selector/src/lib/ErrorAlert.test.tsx b/packages/file-selector/src/lib/ErrorAlert.test.tsx index 74ad1ae1b..85313e8e4 100644 --- a/packages/file-selector/src/lib/ErrorAlert.test.tsx +++ b/packages/file-selector/src/lib/ErrorAlert.test.tsx @@ -12,6 +12,7 @@ describe('ErrorAlert', () => { describe('formatFileTooLarge', () => { test('should format file too large error message', () => { + // 10 MiB = 10 * 1024 * 1024 = 10485760 bytes const formattedError = formatFileTooLarge('File is larger than 10485760 bytes'); expect(formattedError).toEqual('File is larger than 10 MB'); diff --git a/packages/file-selector/src/lib/ErrorAlert.tsx b/packages/file-selector/src/lib/ErrorAlert.tsx index f551baef1..ece05c04b 100644 --- a/packages/file-selector/src/lib/ErrorAlert.tsx +++ b/packages/file-selector/src/lib/ErrorAlert.tsx @@ -1,7 +1,7 @@ import { Alert, AlertTitle } from '@availity/mui-alert'; import { List, ListItem } from '@availity/mui-list'; -import type { FileRejection } from 'react-dropzone'; -import { formatBytes } from './util'; +import type { FileError, FileRejection } from 'react-dropzone'; +import { dedupeErrors, formatBytes } from './util'; const codes: Record = { 'file-too-large': 'File exceeds maximum size', @@ -12,6 +12,8 @@ const codes: Record = { 'duplicate-name': 'Duplicate file selected', }; +export type GetErrorMessageFn = (error: FileError) => string | null | undefined; + export type ErrorAlertProps = { /** * Array of file rejection errors @@ -25,6 +27,10 @@ export type ErrorAlertProps = { * Unique identifier for the error alert */ id: number; + /** + * Custom function to format error messages. Return null/undefined to use default message. + */ + getErrorMessage?: GetErrorMessageFn; onClose: () => void; }; @@ -41,15 +47,30 @@ export const formatFileTooLarge = (message: string): string => { return message.replace(` bytes`, '').replace(FILE_SIZE_REGEX, formattedSize); }; -export const ErrorAlert = ({ errors, fileName, id, onClose }: ErrorAlertProps) => { +export const ErrorAlert = ({ errors, fileName, id, getErrorMessage, onClose }: ErrorAlertProps) => { if (errors.length === 0) return null; + // Dedupe errors by code (e.g., react-dropzone may produce multiple file-invalid-type errors) + const uniqueErrors = dedupeErrors(errors); + + const formatMessage = (error: FileRejection['errors'][0]) => { + // Try custom error message first + const customMessage = getErrorMessage?.(error); + if (customMessage) return customMessage; + + // Fall back to default formatting + if (error.code === 'file-too-large') { + return formatFileTooLarge(error.message); + } + return error.message; + }; + return ( - {errors.length > 1 ? ( + {uniqueErrors.length > 1 ? ( <> - There were {errors.length} error(s) found when uploading {fileName} + There were {uniqueErrors.length} error(s) found when uploading {fileName} <> - {errors.map((error) => { - const message = error.code === 'file-too-large' ? formatFileTooLarge(error.message) : error.message; - return ( - - {message} - - ); - })} + {uniqueErrors.map((error) => ( + + {formatMessage(error)} + + ))} ) : ( <> - {codes[errors[0].code] || 'Error'}: {fileName} + {codes[uniqueErrors[0].code] || 'Error'}: {fileName} - {errors[0].code === 'file-too-large' ? formatFileTooLarge(errors[0].message) : errors[0].message} + {formatMessage(uniqueErrors[0])} )} diff --git a/packages/file-selector/src/lib/FileSelector.tsx b/packages/file-selector/src/lib/FileSelector.tsx index 3d5ceb7dc..dbaf459dd 100644 --- a/packages/file-selector/src/lib/FileSelector.tsx +++ b/packages/file-selector/src/lib/FileSelector.tsx @@ -9,7 +9,7 @@ import { Typography } from '@availity/mui-typography'; import { Alert, AlertTitle } from '@availity/mui-alert'; import { Dropzone } from './Dropzone'; -import { ErrorAlert } from './ErrorAlert'; +import { ErrorAlert, GetErrorMessageFn } from './ErrorAlert'; import { FileList } from './FileList'; import { FileTypesMessage } from './FileTypesMessage'; import { HeaderMessage } from './HeaderMessage'; @@ -147,6 +147,12 @@ export type FileSelectorProps = { * @default false */ disableRemove?: boolean; + /** + * Custom function to format error messages. Return undefined to use default message. + * @param error - The file error object with code and message + * @returns Custom error message string, or undefined to use default + */ + getErrorMessage?: GetErrorMessageFn; }; export const FileSelector = ({ @@ -177,6 +183,7 @@ export const FileSelector = ({ uploadOptions, validator, disableRemove, + getErrorMessage, }: FileSelectorProps) => { const [totalSize, setTotalSize] = useState(0); const [fileRejections, setFileRejections] = useState<(FileRejection & { id: number })[]>([]); @@ -323,6 +330,7 @@ export const FileSelector = ({ errors={rejection.errors} fileName={rejection.file.name} id={rejection.id} + getErrorMessage={getErrorMessage} onClose={() => handleRemoveRejection(rejection.id)} /> ))} diff --git a/packages/file-selector/src/lib/FileSelector2.tsx b/packages/file-selector/src/lib/FileSelector2.tsx index a203a4d22..ab164cd25 100644 --- a/packages/file-selector/src/lib/FileSelector2.tsx +++ b/packages/file-selector/src/lib/FileSelector2.tsx @@ -7,7 +7,7 @@ import { Typography } from '@availity/mui-typography'; import { Alert, AlertTitle } from '@availity/mui-alert'; import { Dropzone2 } from './Dropzone2'; -import { ErrorAlert } from './ErrorAlert'; +import { ErrorAlert, GetErrorMessageFn } from './ErrorAlert'; import { FileList2 } from './FileList2'; import { FileTypesMessage } from './FileTypesMessage'; import { HeaderMessage } from './HeaderMessage'; @@ -18,6 +18,12 @@ import type { FileSelectorProps } from './FileSelector'; export type FileSelector2Props = Omit & { onUploadRemove?: (uploads: Upload[], removedUploadId: string) => void; + /** + * Custom function to format error messages. Return undefined to use default message. + * @param error - The file error object with code and message + * @returns Custom error message string, or undefined to use default + */ + getErrorMessage?: GetErrorMessageFn; }; /** @@ -56,6 +62,7 @@ export const FileSelector2 = ({ uploadOptions, validator, disableRemove, + getErrorMessage, }: FileSelector2Props) => { const [totalSize, setTotalSize] = useState(0); const [fileRejections, setFileRejections] = useState<(FileRejection & { id: number })[]>([]); @@ -200,6 +207,7 @@ export const FileSelector2 = ({ errors={rejection.errors} fileName={rejection.file.name} id={rejection.id} + getErrorMessage={getErrorMessage} onClose={() => handleRemoveRejection(rejection.id)} /> ))} diff --git a/packages/file-selector/src/lib/UploadProgressBar.tsx b/packages/file-selector/src/lib/UploadProgressBar.tsx index 3af621303..e18f02b8e 100644 --- a/packages/file-selector/src/lib/UploadProgressBar.tsx +++ b/packages/file-selector/src/lib/UploadProgressBar.tsx @@ -42,6 +42,10 @@ const ERROR_MAPPINGS: ErrorMapping[] = [ pattern: /but has an extension for/i, friendlyMessage: 'File format does not match file extension.', }, + { + pattern: /tus: failed to upload/i, + friendlyMessage: 'Oops! There was an issue trying to upload your attachment.', + }, ]; const friendlyErrorMessage = (errorMessage: string): string => { diff --git a/packages/file-selector/src/lib/util.ts b/packages/file-selector/src/lib/util.ts index b457fabb2..6949f1607 100644 --- a/packages/file-selector/src/lib/util.ts +++ b/packages/file-selector/src/lib/util.ts @@ -21,7 +21,7 @@ export function formatBytes(bytes: number, decimals = 2) { const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${Number.parseFloat((bytes / k**i).toFixed(dm))} ${sizes[i]}`; + return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; } export const FILE_EXT_ICONS = { png: FileImageIcon,