import { Matcher, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { act } from 'react'; import { Status } from '../common'; import { Field } from '../field/Field'; import { mockMatchMedia, render, screen, waitFor } from '../test-utils'; import UploadInput, { UploadInputProps } from './UploadInput'; import { TEST_IDS as UPLOAD_BUTTON_TEST_IDS } from './uploadButton/UploadButton'; const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync }); const deleteFileAndWaitForFocus = async (fileToDeleteTestId: Matcher, nextFocusTestId: Matcher) => { const fileToDelete = screen.getByTestId(fileToDeleteTestId); await user.click(within(fileToDelete).getByLabelText('Remove file', { exact: false })); const removeButton = screen.queryByText('Remove'); if (removeButton) { await user.click(removeButton); } await waitFor(() => { expect(screen.getByTestId(nextFocusTestId)).toHaveFocus(); }); }; mockMatchMedia(); describe('UploadInput', () => { const pngFile = new File(['foo'], 'foo.png', { type: 'image/png' }); const jpgFile = new File(['foo'], 'foo.jpg', { type: 'image/jpeg' }); const files = [ { id: 1, filename: 'purchase-receipt.pdf', status: Status.SUCCEEDED, }, { id: 2, filename: 'CoWork-0317-invoice.pdf', status: Status.PROCESSING, }, ]; const props: UploadInputProps = { onUploadFile: jest .fn() .mockResolvedValue({ id: 1 }) .mockResolvedValueOnce({ id: 2 }) .mockResolvedValueOnce({ id: 3 }), onDeleteFile: jest.fn().mockResolvedValue({}), }; const renderComponent = (customProps: UploadInputProps = props) => render(); beforeEach(() => { jest.useFakeTimers(); }); afterEach(async () => { await jest.runOnlyPendingTimersAsync(); jest.useRealTimers(); }); describe('single file upload', () => { it('should trigger onUploadFiles & onFilesChange with a single FormData entry containing `file` field', async () => { const date = Date.now(); jest.setSystemTime(date); const onFilesChange = jest.fn(); renderComponent({ ...props, onFilesChange }); const input = screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput); await user.upload(input, [pngFile, jpgFile]); expect(props.onUploadFile).toHaveBeenCalledTimes(1); expect(onFilesChange).toHaveBeenCalledTimes(2); expect(onFilesChange).toHaveBeenNthCalledWith(1, [ { filename: 'foo.png', id: `foo.png_3_${date}`, status: 'pending', }, ]); expect(onFilesChange).toHaveBeenNthCalledWith(2, [ { filename: 'foo.png', id: 2, status: 'succeeded', url: undefined, }, ]); }); it('should render only one file even if multiple ones were supplied', () => { renderComponent({ ...props, files }); expect(screen.getByText(files[0].filename)).toBeInTheDocument(); files.slice(1, files.length).forEach((file) => { expect(screen.queryByText(file.filename)).not.toBeInTheDocument(); }); }); }); describe('multiple file upload', () => { it('should render all files', () => { renderComponent({ ...props, files, multiple: true }); files.forEach((file) => { expect(screen.getByText(file.filename)).toBeInTheDocument(); }); }); it('should render the UploadButton also', () => { renderComponent({ ...props, files, multiple: true }); expect(screen.getByText('Upload files')).toBeInTheDocument(); }); it('should trigger onUploadFile with multiple FormData entries containing `file` fields', async () => { renderComponent({ ...props, multiple: true }); (props.onUploadFile as jest.Mock).mockClear(); expect(screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput)).toHaveAttribute('multiple'); const input = screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput); await user.upload(input, [pngFile, jpgFile]); await waitFor(() => { expect(props.onUploadFile).toHaveBeenCalledTimes(2); }); }); }); describe('file deletion', () => { const onFilesChange = jest.fn(); beforeEach(() => { onFilesChange.mockClear(); }); it('should delete file with modal confirmation', async () => { renderComponent({ ...props, files, multiple: true, onFilesChange, }); const fileToDelete = screen.getByTestId('1-uploadItem'); await act(async () => { within(fileToDelete).getByLabelText('Remove file', { exact: false }).click(); await jest.runOnlyPendingTimersAsync(); }); await act(async () => { screen.getByText('Remove').click(); await jest.runOnlyPendingTimersAsync(); }); await waitFor(() => { expect(screen.queryByTestId('1-uploadItem')).not.toBeInTheDocument(); }); expect(props.onDeleteFile).toHaveBeenCalledWith(files[0].id); expect(onFilesChange).toHaveBeenCalledTimes(2); expect(onFilesChange).toHaveBeenNthCalledWith(1, [ { error: undefined, filename: 'purchase-receipt.pdf', id: 1, status: 'processing', }, { filename: 'CoWork-0317-invoice.pdf', id: 2, status: 'processing', }, ]); expect(onFilesChange).toHaveBeenLastCalledWith([ { filename: 'CoWork-0317-invoice.pdf', id: 2, status: 'processing', }, ]); }); it('should delete file with failed state without modal confirmation', async () => { const files = [ { id: 1, filename: 'purchase-receipt.pdf', status: Status.FAILED, }, ]; renderComponent({ ...props, files, multiple: true, onFilesChange, }); const fileToDelete = screen.getByTestId('1-uploadItem'); await act(async () => { within(fileToDelete).getByLabelText('Remove file', { exact: false }).click(); await jest.runOnlyPendingTimersAsync(); }); expect(fileToDelete).not.toBeInTheDocument(); expect(onFilesChange).toHaveBeenCalledTimes(1); expect(onFilesChange).toHaveBeenCalledWith([]); }); it('should not render delete button when no delete callback is provided', () => { renderComponent({ ...props, files, multiple: true, onDeleteFile: undefined, }); expect(screen.queryByLabelText('Remove file ', { exact: false })).not.toBeInTheDocument(); }); it('should focus the next item after a file is deleted', async () => { const files = [ { id: 1, filename: 'Sales-2024-invoice.pdf', status: Status.DONE }, { id: 2, filename: 'CoWork-0317-invoice.pdf', status: Status.DONE }, { id: 3, filename: 'purchase-receipt.pdf', status: Status.DONE }, ]; renderComponent({ ...props, files, multiple: true, onFilesChange }); // Delete the first file and expect focus to move to the next one await deleteFileAndWaitForFocus('1-uploadItem', '2-action'); }); it('should focus the previous item after the last file is deleted', async () => { const files = [ { id: 1, filename: 'Sales-2024-invoice.pdf', status: Status.DONE, }, { id: 2, filename: 'CoWork-0317-invoice.pdf', status: Status.DONE, }, { id: 3, filename: 'purchase-receipt.pdf', status: Status.DONE, }, ]; renderComponent({ ...props, files, multiple: true, onFilesChange, }); await deleteFileAndWaitForFocus('3-uploadItem', '2-action'); }); it('should focus the upload input after the only file is deleted', async () => { const singleFile = [ { id: 3, filename: 'purchase-receipt.pdf', status: Status.DONE, }, ]; renderComponent({ ...props, files: singleFile, multiple: true, onFilesChange, }); await deleteFileAndWaitForFocus('3-uploadItem', 'uploadInput'); }); it('should focus on the next item or upload input after each file is deleted in sequence', async () => { const filesWithFailed = [ { id: 1, filename: 'Sales-2024-invoice.pdf', status: Status.DONE, }, { id: 2, filename: 'CoWork-0317-invoice.pdf', status: Status.FAILED, }, { id: 3, filename: 'purchase-receipt.pdf', status: Status.DONE, }, ]; renderComponent({ ...props, files: filesWithFailed, multiple: true, onFilesChange, }); await deleteFileAndWaitForFocus('3-uploadItem', '2-action'); await deleteFileAndWaitForFocus('1-uploadItem', '2-action'); await deleteFileAndWaitForFocus('2-uploadItem', 'uploadInput'); }); }); describe('Max File Upload limit', () => { it('should show max file number in the description next to the upload button', async () => { renderComponent({ ...props, multiple: true, maxFiles: 2, }); await waitFor(() => { expect(screen.getByText(/Maximum 2 files\./)).toBeInTheDocument(); }); }); it('should show given error when maxFiles limit is applied and more files are uploaded', async () => { const maxFilesReachedMessage = 'Maximum files message from prop'; const mockOnUploadFileFn = jest.fn(); renderComponent({ ...props, multiple: true, maxFiles: 3, maxFilesErrorMessage: maxFilesReachedMessage, onUploadFile: mockOnUploadFileFn, }); mockOnUploadFileFn.mockImplementation(async (formData: FormData) => { const file = formData.get('file'); return Promise.resolve({ file, id: Math.random() }); }); const input = screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput); await user.upload(input, [pngFile, jpgFile]); const pngFile2 = new File(['foo2'], 'foo2.png', { type: 'image/png' }); const jpgFile2 = new File(['foo2'], 'foo2.jpg', { type: 'image/jpeg' }); await user.upload(input, [pngFile2, jpgFile2]); await waitFor(() => { expect(screen.getByText(maxFilesReachedMessage)).toBeInTheDocument(); }); }); it('should show default error when maxFiles limit exceeds and no error message provided', async () => { const defaultMaxFilesReachedMessage = 'Sorry, this upload failed because we can only accept 2 files at once.'; const mockOnUploadFileFn = jest.fn(); renderComponent({ ...props, multiple: true, maxFiles: 2, onUploadFile: mockOnUploadFileFn, }); mockOnUploadFileFn.mockImplementation(async (formData: FormData) => { const file = formData.get('file'); return Promise.resolve({ file, id: Math.random() }); }); const input = screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput); const pngFile2 = new File(['foo2'], 'foo2.png', { type: 'image/png' }); await user.upload(input, [pngFile, jpgFile, pngFile2]); await waitFor(() => { expect(screen.getByText(defaultMaxFilesReachedMessage)).toBeInTheDocument(); }); }); }); describe('Misc', () => { it('should attach the input id accepted from props to the input components', () => { const inputId = 'testInputId'; const labelText = 'LabelText'; render( <> , ); expect(screen.getByLabelText(labelText)).toHaveAttribute('id', inputId); }); it('should show the file size error when provided in props', async () => { const bytesInaKb = 1024; const twoKbInBytes = 2 * bytesInaKb; const sizeLimitErrorMessage = 'file oversized'; renderComponent({ ...props, sizeLimit: 1, sizeLimitErrorMessage }); const input = screen.getByTestId(UPLOAD_BUTTON_TEST_IDS.uploadInput); const overSizedFile = new File([''], 'testFile.png', { type: 'image/png' }); Object.defineProperty(overSizedFile, 'size', { value: twoKbInBytes }); await user.upload(input, overSizedFile); await waitFor(() => { expect(screen.getByText(sizeLimitErrorMessage)).toBeInTheDocument(); }); }); }); it('supports `Field` for error messages', () => { render( , ); const container = screen.getAllByRole('group')[0]; expect(container).toBeInvalid(); expect(container).toHaveAccessibleDescription('Something went wrong'); }); });