diff --git a/src/common/components/fileUpload/FileUpload.module.scss b/src/common/components/fileUpload/FileUpload.module.scss new file mode 100644 index 000000000..df0616d09 --- /dev/null +++ b/src/common/components/fileUpload/FileUpload.module.scss @@ -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; + } +} diff --git a/src/common/components/fileUpload/FileUpload.test.tsx b/src/common/components/fileUpload/FileUpload.test.tsx new file mode 100644 index 000000000..401c71364 --- /dev/null +++ b/src/common/components/fileUpload/FileUpload.test.tsx @@ -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( + , + ); + 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( + , + 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( + , + ); + 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( + , + ); + fireEvent.drop(screen.getByText('Raahaa tiedostot tänne'), { + dataTransfer: { + files: [file, file2, file3], + }, + }); + + await waitFor(() => { + expect(screen.queryByText('3/3 tiedosto(a) tallennettu')).toBeInTheDocument(); + }); +}); diff --git a/src/common/components/fileUpload/FileUpload.tsx b/src/common/components/fileUpload/FileUpload.tsx new file mode 100644 index 000000000..7368bcf54 --- /dev/null +++ b/src/common/components/fileUpload/FileUpload.tsx @@ -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(null); + const files = useRef([]); + + 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, + totalCount, +}: Readonly<{ + successfulCount: number; + totalCount: number; +}>) { + const { t } = useTranslation(); + + return ( + + +

+ {t('common:components:fileUpload:successNotification', { + successful: successfulCount, + total: totalCount, + })} +

+
+ ); +} + +type Props = { + /** 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; + onUpload?: (isUploading: boolean) => void; +}; + +export default function FileUpload({ + id, + label, + accept, + maxSize, + dragAndDrop, + multiple, + existingAttachments = [], + uploadFunction, + onUpload, +}: Readonly>) { + const { t } = useTranslation(); + const locale = useLocale(); + const [newFiles, setNewFiles] = useState([]); + const [invalidFiles, setInvalidFiles] = useState([]); + 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 ( +
+ + {filesUploading ? ( + + + + {t('common:components:fileUpload:loadingText')} + + + ) : ( + + )} + + + {!filesUploading && newFiles.length > 0 && ( + + )} +
+ ); +} diff --git a/src/common/components/fileUpload/utils.ts b/src/common/components/fileUpload/utils.ts new file mode 100644 index 000000000..2d0fa1236 --- /dev/null +++ b/src/common/components/fileUpload/utils.ts @@ -0,0 +1,15 @@ +import { AttachmentMetadata } from '../../types/attachment'; + +// Filter out duplicate files based on file name +export function removeDuplicateAttachments( + 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]; +} diff --git a/src/common/types/attachment.ts b/src/common/types/attachment.ts new file mode 100644 index 000000000..73ab3075e --- /dev/null +++ b/src/common/types/attachment.ts @@ -0,0 +1,6 @@ +export type AttachmentMetadata = { + id: string; + fileName: string; + createdByUserId: string; + createdAt: string; +}; diff --git a/src/domain/application/attachments.test.ts b/src/domain/application/attachments.test.ts index 8b4fbced1..ecff969f9 100644 --- a/src/domain/application/attachments.test.ts +++ b/src/domain/application/attachments.test.ts @@ -1,4 +1,4 @@ -import { removeDuplicateAttachments } from './attachments'; +import { removeDuplicateAttachments } from '../../common/components/fileUpload/utils'; import { ApplicationAttachmentMetadata } from './types/application'; const attachments: ApplicationAttachmentMetadata[] = [ @@ -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' }, + ], ]); }); @@ -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' }, + ], + [], ]); }); diff --git a/src/domain/application/attachments.ts b/src/domain/application/attachments.ts index 827811c0f..158916c80 100644 --- a/src/domain/application/attachments.ts +++ b/src/domain/application/attachments.ts @@ -4,7 +4,7 @@ import { ApplicationAttachmentMetadata, AttachmentType } from './types/applicati // Get attachments metadata related to an application export async function getAttachments(applicationId: number | null | undefined) { const { data } = await api.get( - `/hakemukset/${applicationId}/liitteet` + `/hakemukset/${applicationId}/liitteet`, ); return data; } @@ -26,7 +26,7 @@ export async function uploadAttachment({ headers: { 'Content-Type': 'multipart/form-data', }, - } + }, ); return data; } @@ -35,7 +35,7 @@ export async function uploadAttachment({ export async function getAttachmentFile(applicationId: number, attachmentId: string) { const { data } = await api.get( `/hakemukset/${applicationId}/liitteet/${attachmentId}/content`, - { responseType: 'blob' } + { responseType: 'blob' }, ); return URL.createObjectURL(data); } @@ -50,13 +50,3 @@ export async function deleteAttachment({ }) { await api.delete(`/hakemukset/${applicationId}/liitteet/${attachmentId}`); } - -// Filter out duplicate files based on file name -export function removeDuplicateAttachments( - files: File[], - attachments: ApplicationAttachmentMetadata[] | undefined -) { - return files.filter((file) => - attachments?.every((attachment) => attachment.fileName !== file.name) - ); -} diff --git a/src/domain/application/types/application.ts b/src/domain/application/types/application.ts index f6ddfc931..fa67f460f 100644 --- a/src/domain/application/types/application.ts +++ b/src/domain/application/types/application.ts @@ -1,3 +1,4 @@ +import { AttachmentMetadata } from './../../../common/types/attachment'; import { Polygon, Position } from 'geojson'; import { Coordinate } from 'ol/coordinate'; import { CRS } from '../../../common/types/hanke'; @@ -100,14 +101,10 @@ export type ApplicationArea = { export type AttachmentType = 'MUU' | 'LIIKENNEJARJESTELY' | 'VALTAKIRJA'; -export type ApplicationAttachmentMetadata = { - id: string; - fileName: string; - createdByUserId: string; - createdAt: string; +export interface ApplicationAttachmentMetadata extends AttachmentMetadata { applicationId: number; attachmentType: AttachmentType; -}; +} export type JohtoselvitysData = { applicationType: ApplicationType; diff --git a/src/domain/johtoselvitys/JohtoselvitysContainer.tsx b/src/domain/johtoselvitys/JohtoselvitysContainer.tsx index 7883019ba..ce45fd6cb 100644 --- a/src/domain/johtoselvitys/JohtoselvitysContainer.tsx +++ b/src/domain/johtoselvitys/JohtoselvitysContainer.tsx @@ -34,9 +34,10 @@ import useHanke from '../hanke/hooks/useHanke'; import { AlluStatus, Application } from '../application/types/application'; import Attachments from './Attachments'; import ConfirmationDialog from '../../common/components/HDSConfirmationDialog/ConfirmationDialog'; -import { removeDuplicateAttachments, uploadAttachment } from '../application/attachments'; +import { uploadAttachment } from '../application/attachments'; import useAttachments from '../application/hooks/useAttachments'; import { APPLICATION_ID_STORAGE_KEY } from '../application/constants'; +import { removeDuplicateAttachments } from '../../common/components/fileUpload/utils'; type Props = { hankeData?: HankeData; @@ -289,7 +290,7 @@ const JohtoselvitysContainer: React.FC> = ({ } // 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({ diff --git a/src/domain/mocks/handlers.ts b/src/domain/mocks/handlers.ts index 4a3408ad7..3143121f5 100644 --- a/src/domain/mocks/handlers.ts +++ b/src/domain/mocks/handlers.ts @@ -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)); + }), ]; diff --git a/src/locales/fi.json b/src/locales/fi.json index 259fe8b08..a305ed34c 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -48,6 +48,10 @@ "errorLoadingInfo": { "textTop": "Virhe tietojen lataamisessa.", "textBottom": "Yritä hetken päästä uudelleen." + }, + "fileUpload": { + "loadingText": "Tallennetaan tiedostoja", + "successNotification": "{{successful}}/{{total}} tiedosto(a) tallennettu" } }, "error": "Tapahtui virhe. Yritä uudestaan.", diff --git a/src/testUtils/render.tsx b/src/testUtils/render.tsx index 30d075783..8a71b3fbf 100644 --- a/src/testUtils/render.tsx +++ b/src/testUtils/render.tsx @@ -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>, ...options,