Skip to content

Commit

Permalink
HAI-2065 Implement file upload component
Browse files Browse the repository at this point in the history
File upload component uses HDS FileInput internally to handle user
adding files from their machine and immediately sends added files
to backend and shows a loading spinner and loading text while
upload is in process.
  • Loading branch information
markohaarni committed Oct 25, 2023
1 parent b4ca294 commit d351dda
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 45 deletions.
127 changes: 127 additions & 0 deletions src/common/components/fileUpload/FileUpload.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { rest } from 'msw';
import { act, fireEvent, render, screen, waitFor } from '../../../testUtils/render';
import api from '../../../domain/api/api';
import FileUpload from './FileUpload';
import { server } from '../../../domain/mocks/test-server';

async function uploadAttachment({ id, file }: { id: number; file: File }) {
const { data } = await api.post(`/hakemukset/${id}/liitteet`, {
liite: file,
});
return data;
}

function uploadFunction(file: File) {
return uploadAttachment({
id: 1,
file,
});
}

test('Should upload files successfully and loading indicator is displayed', async () => {
server.use(
rest.post('/api/hakemukset/:id/liitteet', async (req, res, ctx) => {
return res(ctx.delay(), ctx.status(200));
}),
);

const inputLabel = 'Choose a file';
const { user } = render(
<FileUpload
id="test-file-upload"
label={inputLabel}
accept=".png,.jpg"
multiple
uploadFunction={uploadFunction}
/>,
);
const fileUpload = screen.getByLabelText(inputLabel);
user.upload(fileUpload, [
new File(['test-a'], 'test-file-a.png', { type: 'image/png' }),
new File(['test-b'], 'test-file-b.jpg', { type: 'image/jpg' }),
]);

await waitFor(() => screen.findByText('Tallennetaan tiedostoja'));
await act(async () => {
waitFor(() => expect(screen.queryByText('Tallennetaan tiedostoja')).not.toBeInTheDocument());
});
await waitFor(() => {
expect(screen.queryByText('2/2 tiedosto(a) tallennettu')).toBeInTheDocument();
});
});

test('Should show amount of successful files uploaded correctly when file fails in validation', async () => {
const inputLabel = 'Choose a file';
const { user } = render(
<FileUpload
id="test-file-upload"
label={inputLabel}
accept=".png"
multiple
uploadFunction={uploadFunction}
/>,
undefined,
undefined,
{ applyAccept: false },
);
const fileUpload = screen.getByLabelText(inputLabel);
user.upload(fileUpload, [
new File(['test-a'], 'test-file-a.png', { type: 'image/png' }),
new File(['test-b'], 'test-file-b.pdf', { type: 'application/pdf' }),
]);

await waitFor(() => {
expect(screen.queryByText('1/2 tiedosto(a) tallennettu')).toBeInTheDocument();
});
});

test('Should show amount of successful files uploaded correctly when request fails', async () => {
server.use(
rest.post('/api/hakemukset/:id/liitteet', async (req, res, ctx) => {
return res(ctx.status(400), ctx.json({ errorMessage: 'Failed for testing purposes' }));
}),
);

const inputLabel = 'Choose a file';
const { user } = render(
<FileUpload
id="test-file-upload"
label={inputLabel}
accept=".png,.jpg"
multiple
uploadFunction={uploadFunction}
/>,
);
const fileUpload = screen.getByLabelText(inputLabel);
user.upload(fileUpload, [new File(['test-a'], 'test-file-a.png', { type: 'image/png' })]);

await waitFor(() => {
expect(screen.queryByText('0/1 tiedosto(a) tallennettu')).toBeInTheDocument();
});
});

test('Should upload files when user drops them into drag-and-drop area', async () => {
const inputLabel = 'Choose files';
const file = new File(['test-file'], 'test-file-a', { type: 'image/png' });
const file2 = new File(['test-file'], 'test-file-b', { type: 'image/png' });
const file3 = new File(['test-file'], 'test-file-c', { type: 'image/png' });
render(
<FileUpload
id="test-file-input"
label={inputLabel}
multiple
dragAndDrop
uploadFunction={uploadFunction}
/>,
);
fireEvent.drop(screen.getByText('Raahaa tiedostot tänne'), {
dataTransfer: {
files: [file, file2, file3],
},
});

await waitFor(() => {
expect(screen.queryByText('3/3 tiedosto(a) tallennettu')).toBeInTheDocument();
});
});
59 changes: 28 additions & 31 deletions src/common/components/fileUpload/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { QueryKey, useMutation, useQueryClient } from 'react-query';
import { useMutation } from 'react-query';
import { FileInput, IconCheckCircleFill, LoadingSpinner } from 'hds-react';
import { useTranslation } from 'react-i18next';
import { differenceBy } from 'lodash';
Expand All @@ -11,14 +11,14 @@ import Text from '../text/Text';
import styles from './FileUpload.module.scss';
import { removeDuplicateAttachments } from './utils';

function useDroppedFiles() {
function useDragAndDropFiles() {
const ref = useRef<HTMLDivElement>(null);
const droppedFiles = useRef<File[]>([]);
const files = useRef<File[]>([]);

useEffect(() => {
function dropHandler(ev: DragEvent) {
if (ev.dataTransfer) {
droppedFiles.current = Array.from(ev.dataTransfer.files);
files.current = Array.from(ev.dataTransfer.files);
}
}

Expand All @@ -27,7 +27,7 @@ function useDroppedFiles() {
}
}, []);

return { ref, droppedFiles };
return { ref, files };
}

function SuccessNotification({
Expand All @@ -52,48 +52,47 @@ function SuccessNotification({
);
}

// TODO: Kutsujen perumisen voisi tehdä omassa
// subtaskissaan, se on vielä mysteeri miten tehdään

type Props<T extends AttachmentMetadata> = {
fileInputId: string;
accept: string | undefined;
maxSize: number | undefined;
dragAndDrop: boolean | undefined;
multiple: boolean | undefined;
queryKey: QueryKey;
/** id of the input element */
id: string;
/** Label for the input */
label?: string;
/** A comma-separated list of unique file type specifiers describing file types to allow. */
accept?: string;
/** Maximum file size in bytes. */
maxSize?: number;
/** If true, the file input will have a drag and drop area */
dragAndDrop?: boolean;
/** A Boolean that indicates that more than one file can be chosen */
multiple?: boolean;
existingAttachments?: T[];
/** Function that is given to upload mutation, handling the sending of file to API */
uploadFunction: (file: File) => Promise<T>;
onUpload?: (isUploading: boolean) => void;
};

export default function FileUpload<T extends AttachmentMetadata>({
fileInputId,
id,
label,
accept,
maxSize,
dragAndDrop,
multiple,
queryKey,
existingAttachments = [],
uploadFunction,
onUpload,
}: Props<T>) {
const { t } = useTranslation();
const locale = useLocale();
const queryClient = useQueryClient();
const [newFiles, setNewFiles] = useState<File[]>([]);
const [invalidFiles, setInvalidFiles] = useState<File[]>([]);
const uploadMutation = useMutation(uploadFunction, {
onError(error: AxiosError, file) {
// TODO: Invalid files pitäisi ehkä olla array of objects,
// joissa olisi virheen syy ja tiedosto,
// tai niin, että se on array of strings, joissa jokainen item on se
// error teksti
setInvalidFiles((files) => [...files, file]);
},
});
const [filesUploading, setFilesUploading] = useState(false);
const { ref: dropZoneRef, droppedFiles } = useDroppedFiles();
const { ref: dropZoneRef, files: dragAndDropFiles } = useDragAndDropFiles();

async function uploadFiles(files: File[]) {
setFilesUploading(true);
Expand All @@ -108,27 +107,26 @@ export default function FileUpload<T extends AttachmentMetadata>({
await Promise.allSettled(mutations);

setFilesUploading(false);
queryClient.invalidateQueries(queryKey);
if (onUpload) {
onUpload(false);
}
}

function handleFilesChange(files: File[]) {
// Filter out attachments that have same names as those that have already been sent
const filesToSend = removeDuplicateAttachments(files, existingAttachments);
const [filesToUpload] = removeDuplicateAttachments(files, existingAttachments);

// Determine which files haven't passed HDS FileInput validation by comparing
// files in input element or files dropped into drop zone to files received as
// argument to this onChange function
const inputElem = document.getElementById(fileInputId) as HTMLInputElement;
const inputElem = document.getElementById(id) as HTMLInputElement;
const inputElemFiles = inputElem.files ? Array.from(inputElem.files) : [];
const allFiles = inputElemFiles.length > 0 ? inputElemFiles : droppedFiles.current;
const invalidFilesArr = differenceBy(allFiles, filesToSend, 'name');
const allFiles = inputElemFiles.length > 0 ? inputElemFiles : dragAndDropFiles.current;
const invalidFilesArr = differenceBy(allFiles, filesToUpload, 'name');

setNewFiles(allFiles);
setInvalidFiles(invalidFilesArr);
uploadFiles(filesToSend);
uploadFiles(filesToUpload);
}

return (
Expand All @@ -143,16 +141,15 @@ export default function FileUpload<T extends AttachmentMetadata>({
</Flex>
) : (
<FileInput
id={fileInputId}
id={id}
accept={accept}
label={t('form:labels:dragAttachments')}
label={label ?? t('form:labels:dragAttachments')}
dragAndDropLabel={t('form:labels:dragAttachments')}
language={locale}
maxSize={maxSize}
dragAndDrop={dragAndDrop}
multiple={multiple}
onChange={handleFilesChange}
defaultValue={[]}
/>
)}
</Flex>
Expand Down
8 changes: 6 additions & 2 deletions src/common/components/fileUpload/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import { AttachmentMetadata } from '../../types/attachment';
export function removeDuplicateAttachments<T extends AttachmentMetadata>(
files: File[],
attachments: T[] | undefined,
): File[] {
return files.filter(
): [File[], File[]] {
const duplicateFiles = files.filter(
(file) => attachments?.some((attachment) => attachment.fileName === file.name),
);
const newFiles = files.filter(
(file) => attachments?.every((attachment) => attachment.fileName !== file.name),
);
return [newFiles, duplicateFiles];
}
27 changes: 19 additions & 8 deletions src/domain/application/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ test('Should filter out existing attachments', () => {
];

expect(removeDuplicateAttachments(files, attachments)).toMatchObject([
{ name: 'Haitaton_liite_uusi.pdf' },
{ name: 'Haitaton_liite_uusi_2.jpg' },
[{ name: 'Haitaton_liite_uusi.pdf' }, { name: 'Haitaton_liite_uusi_2.jpg' }],
[
{ name: 'Haitaton_liite.png' },
{ name: 'Haitaton_liite_2.txt' },
{ name: 'Haitaton_liite_3.jpg' },
{ name: 'Haitaton_liite_4.pdf' },
],
]);
});

Expand All @@ -60,14 +65,20 @@ test('Should not filter out anything from only new files', () => {
];

expect(removeDuplicateAttachments(files, attachments)).toMatchObject([
{ name: 'Haitaton_liite_uusi.pdf' },
{ name: 'Haitaton_liite_uusi_2.jpg' },
{ name: 'Haitaton_liite_uusi_3.jpg' },
[
{ name: 'Haitaton_liite_uusi.pdf' },
{ name: 'Haitaton_liite_uusi_2.jpg' },
{ name: 'Haitaton_liite_uusi_3.jpg' },
],
[],
]);

expect(removeDuplicateAttachments(files, [])).toMatchObject([
{ name: 'Haitaton_liite_uusi.pdf' },
{ name: 'Haitaton_liite_uusi_2.jpg' },
{ name: 'Haitaton_liite_uusi_3.jpg' },
[
{ name: 'Haitaton_liite_uusi.pdf' },
{ name: 'Haitaton_liite_uusi_2.jpg' },
{ name: 'Haitaton_liite_uusi_3.jpg' },
],
[],
]);
});
2 changes: 1 addition & 1 deletion src/domain/johtoselvitys/JohtoselvitysContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ const JohtoselvitysContainer: React.FC<React.PropsWithChildren<Props>> = ({
}

// Filter out attachments that have same names as those that have already been sent
const filesToSend = removeDuplicateAttachments(newAttachments, existingAttachments);
const [filesToSend] = removeDuplicateAttachments(newAttachments, existingAttachments);

const mutations = filesToSend.map((file) =>
attachmentUploadMutation.mutateAsync({
Expand Down
4 changes: 4 additions & 0 deletions src/domain/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,8 @@ export const handlers = [
rest.post(`${apiUrl}/kayttajat/:kayttajaId/kutsu`, async (req, res, ctx) => {
return res(ctx.delay(), ctx.status(204));
}),

rest.post(`${apiUrl}/hakemukset/:id/liitteet`, async (req, res, ctx) => {
return res(ctx.delay(), ctx.status(200));
}),
];
2 changes: 1 addition & 1 deletion src/locales/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
},
"fileUpload": {
"loadingText": "Tallennetaan tiedostoja",
"successNotification": "{{successful}}/{{total}} tiedostoa tallennettu"
"successNotification": "{{successful}}/{{total}} tiedosto(a) tallennettu"
}
},
"error": "Tapahtui virhe. Yritä uudestaan.",
Expand Down
10 changes: 8 additions & 2 deletions src/testUtils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ const AllTheProviders = ({ children }: Props) => {
);
};

const customRender = (ui: React.ReactElement, options: RenderOptions = {}, route = '/') => {
const customRender = (
ui: React.ReactElement,
options: RenderOptions = {},
route = '/',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userEventOptions?: any,
) => {
window.history.pushState({}, 'Test page', route);
window.scrollTo = function () {};
return {
user: userEvent.setup(),
user: userEvent.setup(userEventOptions),
...render(ui, {
wrapper: AllTheProviders as React.ComponentType<React.PropsWithChildren<unknown>>,
...options,
Expand Down

0 comments on commit d351dda

Please sign in to comment.