Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HAI-2065 Implement file upload component #398

Merged
merged 4 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/common/components/fileUpload/FileUpload.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.uploadContainer {
min-height: 250px;
margin-bottom: var(--spacing-s);
}

.loadingContainer {
margin-left: var(--spacing-m);

.loadingSpinner {
margin-right: var(--spacing-s);
}

.loadingText {
font-weight: 500;
}
}
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();
});
});
165 changes: 165 additions & 0 deletions src/common/components/fileUpload/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation } from 'react-query';
import { FileInput, IconCheckCircleFill, LoadingSpinner } from 'hds-react';
import { useTranslation } from 'react-i18next';
import { differenceBy } from 'lodash';
import { AxiosError } from 'axios';
import useLocale from '../../hooks/useLocale';
import { AttachmentMetadata } from '../../types/attachment';
import { Flex } from '@chakra-ui/react';
import Text from '../text/Text';
import styles from './FileUpload.module.scss';
import { removeDuplicateAttachments } from './utils';

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

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

if (ref.current) {
ref.current.ondrop = dropHandler;
}
}, []);

return { ref, files };
}

function SuccessNotification({
successfulCount,
pitkni marked this conversation as resolved.
Show resolved Hide resolved
totalCount,
}: Readonly<{
successfulCount: number;
totalCount: number;
}>) {
const { t } = useTranslation();

return (
<Flex color="var(--color-success)">
<IconCheckCircleFill style={{ marginRight: 'var(--spacing-2-xs)' }} />
<p>
{t('common:components:fileUpload:successNotification', {
successful: successfulCount,
total: totalCount,
})}
</p>
</Flex>
);
}

type Props<T extends AttachmentMetadata> = {
/** 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>({
id,
label,
accept,
maxSize,
dragAndDrop,
multiple,
existingAttachments = [],
uploadFunction,
onUpload,
}: Readonly<Props<T>>) {
const { t } = useTranslation();
const locale = useLocale();
const [newFiles, setNewFiles] = useState<File[]>([]);
const [invalidFiles, setInvalidFiles] = useState<File[]>([]);
const uploadMutation = useMutation(uploadFunction, {
onError(error: AxiosError, file) {
setInvalidFiles((files) => [...files, file]);
},
});
const [filesUploading, setFilesUploading] = useState(false);
const { ref: dropZoneRef, files: dragAndDropFiles } = useDragAndDropFiles();

async function uploadFiles(files: File[]) {
setFilesUploading(true);
if (onUpload) {
onUpload(true);
}

const mutations = files.map((file) => {
return uploadMutation.mutateAsync(file);
});

await Promise.allSettled(mutations);

setFilesUploading(false);
if (onUpload) {
onUpload(false);
}
}

function handleFilesChange(files: File[]) {
// Filter out attachments that have same names as those that have already been sent
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(id) as HTMLInputElement;
const inputElemFiles = inputElem.files ? Array.from(inputElem.files) : [];
const allFiles = inputElemFiles.length > 0 ? inputElemFiles : dragAndDropFiles.current;
const invalidFilesArr = differenceBy(allFiles, filesToUpload, 'name');

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

return (
<div>
<Flex alignItems="center" className={styles.uploadContainer} ref={dropZoneRef}>
{filesUploading ? (
<Flex className={styles.loadingContainer}>
<LoadingSpinner small className={styles.loadingSpinner} />
<Text tag="p" className={styles.loadingText}>
{t('common:components:fileUpload:loadingText')}
</Text>
</Flex>
) : (
<FileInput
id={id}
accept={accept}
label={label ?? t('form:labels:dragAttachments')}
dragAndDropLabel={t('form:labels:dragAttachments')}
language={locale}
maxSize={maxSize}
dragAndDrop={dragAndDrop}
multiple={multiple}
onChange={handleFilesChange}
/>
)}
</Flex>

{!filesUploading && newFiles.length > 0 && (
<SuccessNotification
successfulCount={newFiles.length - invalidFiles.length}
totalCount={newFiles.length}
/>
)}
</div>
);
}
15 changes: 15 additions & 0 deletions src/common/components/fileUpload/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AttachmentMetadata } from '../../types/attachment';

// Filter out duplicate files based on file name
export function removeDuplicateAttachments<T extends AttachmentMetadata>(
addedFiles: File[],
existingAttachments: T[] | undefined,
): [File[], File[]] {
const duplicateFiles = addedFiles.filter(
(file) => existingAttachments?.some((attachment) => attachment.fileName === file.name),
);
const newFiles = addedFiles.filter(
(file) => existingAttachments?.every((attachment) => attachment.fileName !== file.name),
);
return [newFiles, duplicateFiles];
pitkni marked this conversation as resolved.
Show resolved Hide resolved
}
6 changes: 6 additions & 0 deletions src/common/types/attachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type AttachmentMetadata = {
id: string;
fileName: string;
createdByUserId: string;
createdAt: string;
};
29 changes: 20 additions & 9 deletions src/domain/application/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { removeDuplicateAttachments } from './attachments';
import { removeDuplicateAttachments } from '../../common/components/fileUpload/utils';
import { ApplicationAttachmentMetadata } from './types/application';

const attachments: ApplicationAttachmentMetadata[] = [
Expand Down 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' },
],
[],
]);
});
Loading
Loading