From d06121ec6d3ab292ab2950891f6cee832da5d12f Mon Sep 17 00:00:00 2001 From: amandaDavisAvaility <117665061+amandaDavisAvaility@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:49:59 -0500 Subject: [PATCH 1/3] feat(file-selector): add getErrorMessage prop and improve error handling - Add getErrorMessage prop to ErrorAlert, FileSelector, and FileSelector2 allowing consumers to customize error messages - Add error deduplication in ErrorAlert using dedupeErrors utility - Add TUS upload error mapping for friendly error messages - Change formatBytes to use SI standard (1000 divisor) instead of 1024 --- packages/file-selector/src/lib/ErrorAlert.tsx | 48 +++++++++++++------ .../file-selector/src/lib/FileSelector.tsx | 10 +++- .../file-selector/src/lib/FileSelector2.tsx | 10 +++- .../src/lib/UploadProgressBar.tsx | 4 ++ packages/file-selector/src/lib/util.ts | 2 +- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/file-selector/src/lib/ErrorAlert.tsx b/packages/file-selector/src/lib/ErrorAlert.tsx index f551baef1d..ece05c04bb 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 3d5ceb7dc4..dbaf459ddb 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 a203a4d22c..ab164cd253 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 3af6213032..e18f02b8e9 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 b457fabb24..8ef1a8c88a 100644 --- a/packages/file-selector/src/lib/util.ts +++ b/packages/file-selector/src/lib/util.ts @@ -15,7 +15,7 @@ import type { FileError } from 'react-dropzone'; export function formatBytes(bytes: number, decimals = 2) { if (!+bytes) return '0 Bytes'; - const k = 1024; + const k = 1000; // SI standard (decimal) const dm = Math.max(decimals, 0); const sizes = ['Bytes', 'KB', 'MB', 'GB']; From f6d86c975239369ac22875baa562cc5c291a2696 Mon Sep 17 00:00:00 2001 From: amandaDavisAvaility <117665061+amandaDavisAvaility@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:20:29 -0500 Subject: [PATCH 2/3] test(file-selector): use SI-compatible byte value in test --- packages/file-selector/src/lib/ErrorAlert.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/file-selector/src/lib/ErrorAlert.test.tsx b/packages/file-selector/src/lib/ErrorAlert.test.tsx index 74ad1ae1b7..0c67114cac 100644 --- a/packages/file-selector/src/lib/ErrorAlert.test.tsx +++ b/packages/file-selector/src/lib/ErrorAlert.test.tsx @@ -12,7 +12,8 @@ describe('ErrorAlert', () => { describe('formatFileTooLarge', () => { test('should format file too large error message', () => { - const formattedError = formatFileTooLarge('File is larger than 10485760 bytes'); + // 10000000 bytes = 10 MB with SI standard (1000 divisor) + const formattedError = formatFileTooLarge('File is larger than 10000000 bytes'); expect(formattedError).toEqual('File is larger than 10 MB'); }); From 04f15cc71e289e99343d4c1606e42f05a2881255 Mon Sep 17 00:00:00 2001 From: amandaDavisAvaility <117665061+amandaDavisAvaility@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:37:33 -0500 Subject: [PATCH 3/3] feat(file-selector): add getErrorMessage prop and improve error handling --- .../file-selector/src/lib/ErrorAlert.test.tsx | 3 +- packages/file-selector/src/lib/ErrorAlert.tsx | 48 +++++++++++++------ .../file-selector/src/lib/FileSelector.tsx | 10 +++- .../file-selector/src/lib/FileSelector2.tsx | 10 +++- .../src/lib/UploadProgressBar.tsx | 4 ++ packages/file-selector/src/lib/util.ts | 2 +- 6 files changed, 58 insertions(+), 19 deletions(-) diff --git a/packages/file-selector/src/lib/ErrorAlert.test.tsx b/packages/file-selector/src/lib/ErrorAlert.test.tsx index 74ad1ae1b7..0c67114cac 100644 --- a/packages/file-selector/src/lib/ErrorAlert.test.tsx +++ b/packages/file-selector/src/lib/ErrorAlert.test.tsx @@ -12,7 +12,8 @@ describe('ErrorAlert', () => { describe('formatFileTooLarge', () => { test('should format file too large error message', () => { - const formattedError = formatFileTooLarge('File is larger than 10485760 bytes'); + // 10000000 bytes = 10 MB with SI standard (1000 divisor) + const formattedError = formatFileTooLarge('File is larger than 10000000 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 f551baef1d..ece05c04bb 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 3d5ceb7dc4..dbaf459ddb 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 a203a4d22c..ab164cd253 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 3af6213032..e18f02b8e9 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 b457fabb24..8ef1a8c88a 100644 --- a/packages/file-selector/src/lib/util.ts +++ b/packages/file-selector/src/lib/util.ts @@ -15,7 +15,7 @@ import type { FileError } from 'react-dropzone'; export function formatBytes(bytes: number, decimals = 2) { if (!+bytes) return '0 Bytes'; - const k = 1024; + const k = 1000; // SI standard (decimal) const dm = Math.max(decimals, 0); const sizes = ['Bytes', 'KB', 'MB', 'GB'];