import { clsx } from 'clsx'; import { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import Button from '../button'; import { CommonProps, ControlType, Priority, Status } from '../common'; import { useInputAttributes } from '../inputs/contexts'; import Modal from '../modal'; import { isSizeValid } from '../upload/utils/isSizeValid'; import { isTypeValid } from '../upload/utils/isTypeValid'; import MESSAGES from './UploadInput.messages'; import { UploadedFile, UploadError, UploadResponse } from './types'; import UploadButton, { UploadButtonProps } from './uploadButton/UploadButton'; import { DEFAULT_SIZE_LIMIT, imageFileTypes } from './uploadButton/defaults'; import UploadItem, { UploadItemProps } from './uploadItem/UploadItem'; export type UploadInputProps = { /** * List of already existing, failed or in progress files * @default [] */ files?: readonly UploadedFile[]; /** * The key of the file in the returned FormData object (default: file) * @default 'file' */ fileInputName?: string; /** * Callback that handles form submission * * @param formData */ onUploadFile: (formData: FormData) => Promise; /** * Provide a callback if the file can be removed/deleted from the server * Your app is responsible for reloading the uploaded files list and updating the component to ensure that the file has in fact been deleted successfully * * @param id */ onDeleteFile?: (id: string | number) => Promise; /** * Provide a callback to trigger on validation error * * @param file */ onValidationError?: (file: UploadedFile) => void; /** * Provide a callback to trigger on change whenever the files are updated * * @param files */ onFilesChange?: (files: UploadedFile[]) => void; /** * Confirmation modal displayed on delete */ deleteConfirm?: { /** * The title of the confirmation modal on delete */ title?: string; /** * The body of the confirmation modal on delete */ body?: React.ReactNode; /** * The confirm button text of the confirmation modal on delete */ confirmText?: string; /** * The cancel button text of the confirmation modal on delete */ cancelText?: string; }; /** * Maximum number of files allowed, if provided, shows error below file item */ maxFiles?: number; /** * Error message to show when the maximum number of files are uploaded already */ maxFilesErrorMessage?: string; /** * Error message to show when files over allowed size limit are uploaded */ sizeLimitErrorMessage?: string; } & { /** @default false */ multiple?: UploadButtonProps['multiple']; /** @default ['.pdf,application/pdf', '.jpg,.jpeg,image/jpeg', '.png,image/png'] */ fileTypes?: UploadButtonProps['fileTypes']; /** @default 5000 */ sizeLimit?: UploadButtonProps['sizeLimit']; } & Pick & { onDownload?: UploadItemProps['onDownload']; } & CommonProps; /** * Interface representing a reference to an UploadItem component. * Provides a method to focus the UploadItem. */ interface UploadItemRef { /** * Focuses the UploadItem component. */ focus: () => void; /** * Required id of the UploadItem component. */ id: string | number; /** * Optional status of the UploadItem component. */ status?: string; } /** * Generates a unique ID for a file based on its name, size, and the current timestamp */ function generateFileId(file: File) { const { name, size } = file; const uploadTimeStamp = new Date().getTime(); return `${name}_${size}_${uploadTimeStamp}`; } /** * The component allows users to upload files, manage the list of uploaded files, * and handle file validation and deletion. * * @param {UploadInputProps} props - The properties for the UploadInput component. * * @see {@link UploadInput} for further information. * @see {@link https://storybook.wise.design/?path=/docs/forms-uploadinput--docs|Storybook Wise Design} */ const UploadInput = ({ files = [], fileInputName = 'file', className, deleteConfirm, disabled, multiple = false, fileTypes = imageFileTypes, sizeLimit = DEFAULT_SIZE_LIMIT, description, onUploadFile, onDeleteFile, onValidationError, onFilesChange, onDownload, maxFiles, maxFilesErrorMessage, id, sizeLimitErrorMessage, uploadButtonTitle, }: UploadInputProps) => { const inputAttributes = useInputAttributes({ nonLabelable: true }); const [markedFileForDelete, setMarkedFileForDelete] = useState(null); const [lastAttemptedDeleteId, setLastAttemptedDeleteId] = useState(null); const [mounted, setMounted] = useState(false); const { formatMessage } = useIntl(); const uploadInputRef = useRef(null); let fileRefs: (HTMLDivElement | UploadItemRef | null)[] = []; const PROGRESS_STATUSES = new Set([Status.PENDING, Status.PROCESSING]); const [uploadedFiles, setUploadedFiles] = useState( multiple || files.length === 0 ? files : [files[0]], ); const uploadedFilesListReference = useRef(multiple || files.length === 0 ? files : [files[0]]); function updateFileList(updateFn: (list: readonly UploadedFile[]) => readonly UploadedFile[]) { setUploadedFiles(updateFn); uploadedFilesListReference.current = updateFn(uploadedFilesListReference.current); } function addFileToList(recentUploadedFile: UploadedFile) { updateFileList((list) => [...list, recentUploadedFile]); } function removeFileFromList(file: UploadedFile) { updateFileList((list) => list.filter((fileInList) => file !== fileInList && file.id !== fileInList.id), ); fileRefs = fileRefs.filter((ref) => ref && ref.id !== file.id); } function modifyFileInList(file: UploadedFile, updates: Partial) { updateFileList((list) => list.map((fileInList) => fileInList === file || fileInList.id === file.id ? { ...file, ...updates } : fileInList, ), ); } const removeFile = async (file: UploadedFile) => { const { id, status } = file; fileRefs = fileRefs.filter((item) => item && item.id !== file.id); if (status === Status.FAILED) { removeFileFromList(file); return Promise.resolve(); } if (onDeleteFile && id) { modifyFileInList(file, { status: Status.PROCESSING, error: undefined }); return onDeleteFile(id) .then(() => { removeFileFromList(file); }) .catch((error) => { modifyFileInList(file, { error: error as UploadError }); }); } }; function handleFileUploadFailure(file: File, failureMessage: string) { const { name } = file; const failedUpload = { id: generateFileId(file), filename: name, status: Status.FAILED, error: failureMessage, }; addFileToList(failedUpload); if (onValidationError) { onValidationError(failedUpload); } } function getNumberOfFilesUploaded() { const uploadInitiatedStatus = new Set([Status.SUCCEEDED, Status.PENDING]); const validFiles = uploadedFilesListReference.current.filter( (file) => file.status && uploadInitiatedStatus.has(file.status), ); return validFiles.length; } function areMaximumFilesUploadedAlready() { if (!maxFiles) { return false; } const numberOfValidFiles = getNumberOfFilesUploaded(); return numberOfValidFiles >= maxFiles; } const addFiles = (selectedFiles: FileList) => { for (let i = 0; i < selectedFiles.length; i += 1) { const file = selectedFiles.item(i); const formData = new FormData(); if (file) { const allowedFileTypes = typeof fileTypes === 'string' ? fileTypes : fileTypes.join(','); if (!isTypeValid(file, allowedFileTypes)) { handleFileUploadFailure(file, formatMessage(MESSAGES.fileTypeNotSupported)); continue; } if (typeof sizeLimit === 'number' && !isSizeValid(file, sizeLimit * 1000)) { const failureMessage = sizeLimitErrorMessage || formatMessage(MESSAGES.fileIsTooLarge); handleFileUploadFailure(file, failureMessage); continue; } if (areMaximumFilesUploadedAlready()) { const failureMessage = maxFilesErrorMessage || formatMessage(MESSAGES.maximumFilesAlreadyUploaded, { maxFilesAllowed: maxFiles }); handleFileUploadFailure(file, failureMessage); continue; } const existingFile = uploadedFiles.find((f) => f.filename === file.name); if (existingFile) { removeFileFromList(existingFile); } formData.append(fileInputName, file); const pendingFile = { id: generateFileId(file), filename: file.name, status: Status.PENDING, }; addFileToList(pendingFile); onUploadFile(formData) .then(({ id, url, error }: UploadResponse) => { modifyFileInList(pendingFile, { id, url, error, status: Status.SUCCEEDED }); }) .catch((error) => { modifyFileInList(pendingFile, { error: error as UploadError, status: Status.FAILED }); }); if (!multiple) { break; } } } }; useEffect(() => { setMounted(true); }, []); useEffect(() => { if (onFilesChange && mounted) { onFilesChange([...uploadedFiles]); } }, [onFilesChange, uploadedFiles]); // eslint-disable-line react-hooks/exhaustive-deps type NextFocusable = | HTMLDivElement | UploadItemRef | { ref: HTMLDivElement | UploadItemRef; target: 'button' | 'link' } | null; const [nextFocusable, setNextFocusable] = useState(uploadInputRef.current); const handleFocus = (fileId: string | number) => { fileRefs = fileRefs.filter((ref) => { return ref && ref.id !== markedFileForDelete?.id; }); const filesCount = fileRefs.length; let next: UploadItemRef | HTMLDivElement | null = uploadInputRef.current; let focusTarget: 'button' | 'link' = 'button'; // If there will be no files left after deletion, focus the upload button if (filesCount === 1) { next = uploadInputRef.current; setNextFocusable(next); return; } if (filesCount > 1) { const currentFileIndex = fileRefs.findIndex((file) => file?.id === fileId); const currentFileId = fileRefs?.[currentFileIndex]?.id; const lastFileId = fileRefs?.[filesCount - 1]?.id; // if last file, select a previous one if (currentFileId === lastFileId) { next = fileRefs[filesCount - 2]; } else { next = fileRefs[currentFileIndex + 1]; } // If next is an UploadItemRef, check if it has a URL (succeeded) if (next && 'status' in next) { // Find the file object for this ref const fileObj = uploadedFiles.find((f) => f.id === next?.id); if ( fileObj && (fileObj.status === Status.SUCCEEDED || fileObj.status === Status.DONE) && fileObj.url ) { focusTarget = 'link'; } } setNextFocusable(() => { if (next && typeof (next as UploadItemRef).focus === 'function') { return { ref: next, target: focusTarget }; } return next; }); } }; const handleRefocus = () => { const focusTarget = nextFocusable; if (lastAttemptedDeleteId) { setLastAttemptedDeleteId(null); return; } if (focusTarget) { // If there are no files left, focus the upload button if ( uploadedFiles.length === 0 && uploadInputRef.current && typeof uploadInputRef.current.focus === 'function' ) { setTimeout(() => { uploadInputRef.current!.focus(); }, 0); } else if ( typeof focusTarget === 'object' && 'ref' in focusTarget && focusTarget.ref && typeof focusTarget.ref.focus === 'function' ) { setTimeout(() => { if (focusTarget.ref && typeof (focusTarget.ref as UploadItemRef).focus === 'function') { // @ts-expect-error: focus may not exist on all possible ref types, but is safe here (focusTarget.ref as UploadItemRef).focus(focusTarget.target); } }, 0); } else if (focusTarget && typeof (focusTarget as UploadItemRef).focus === 'function') { setTimeout(() => { (focusTarget as UploadItemRef).focus(); }, 0); } } }; return ( <>
{uploadedFiles.map((file, index) => ( { if ( el && el.id !== markedFileForDelete?.id && !fileRefs.some((ref) => ref && ref.id === el.id) && el.status !== 'processing' ) { fileRefs.push(el); } }} file={file} singleFileUpload={!multiple} canDelete={ (!!onDeleteFile || file.status === Status.FAILED) && (!file.status || !PROGRESS_STATUSES.has(file.status)) } onDelete={ file.status === Status.FAILED ? async () => { setLastAttemptedDeleteId(file.id); await removeFile(file); handleRefocus(); } : () => { setLastAttemptedDeleteId(file.id); setMarkedFileForDelete(file); } } onDownload={onDownload} onFocus={() => handleFocus(file.id)} /> ))}
{(multiple || (!multiple && !uploadedFiles.length)) && (
)}
} onUnmount={handleRefocus} onClose={() => { setMarkedFileForDelete(null); }} /> ); }; export default UploadInput;