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');
});
});