From 49dda13860fc3f0c510e188a5bc7233b058f70da Mon Sep 17 00:00:00 2001 From: Marko Haarni Date: Tue, 31 Oct 2023 12:36:11 +0200 Subject: [PATCH] HAI-2066 File list for upload component Implemented file list to list uploaded files. Files can be downloaded from the links in the list. Files can be deleted from the list. When user clicks delete button, a confirmation dialog is shown. --- .../fileDownloadLink/FileDownloadLink.tsx | 13 ++- src/common/components/fileUpload/FileList.tsx | 50 +++++++++-- .../fileUpload/FileListItem.module.scss | 20 +++-- .../components/fileUpload/FileListItem.tsx | 55 ++++++------ .../components/fileUpload/FileUpload.test.tsx | 87 ++++++++++++++++++- .../components/fileUpload/FileUpload.tsx | 16 +++- src/common/components/fileUpload/types.ts | 2 + src/domain/mocks/handlers.ts | 4 + src/locales/fi.json | 7 +- 9 files changed, 199 insertions(+), 55 deletions(-) diff --git a/src/common/components/fileDownloadLink/FileDownloadLink.tsx b/src/common/components/fileDownloadLink/FileDownloadLink.tsx index 0111c26ea..786292092 100644 --- a/src/common/components/fileDownloadLink/FileDownloadLink.tsx +++ b/src/common/components/fileDownloadLink/FileDownloadLink.tsx @@ -23,23 +23,21 @@ function FileDownloadLink({ }: Props) { const { t } = useTranslation(); const queryClient = useQueryClient(); - const [fileUrl, setFileUrl] = useState(''); + const [fileUrl, setFileUrl] = useState(queryClient.getQueryData(queryKey) ?? ''); const linkRef = useRef(null); const [errorText, setErrorText] = useState(null); const [loading, setLoading] = useState(false); + const [fileFetched, setFileFetched] = useState(false); useEffect(() => { - if (fileUrl) { + if (fileFetched) { linkRef.current?.click(); } - }, [fileUrl]); + }, [fileFetched]); async function fetchFile(event: React.MouseEvent) { setErrorText(null); - const cachedUrl = queryClient.getQueryData(queryKey); - if (cachedUrl) { - setFileUrl(cachedUrl); - } else { + if (!fileUrl) { event.preventDefault(); setLoading(true); try { @@ -49,6 +47,7 @@ function FileDownloadLink({ }); setFileUrl(url); setLoading(false); + setFileFetched(true); } catch (error) { setLoading(false); setErrorText(t('common:error')); diff --git a/src/common/components/fileUpload/FileList.tsx b/src/common/components/fileUpload/FileList.tsx index 55b2b43a0..45c8d468e 100644 --- a/src/common/components/fileUpload/FileList.tsx +++ b/src/common/components/fileUpload/FileList.tsx @@ -6,13 +6,16 @@ import { useMutation } from 'react-query'; import { AttachmentMetadata } from '../../types/attachment'; import ConfirmationDialog from '../HDSConfirmationDialog/ConfirmationDialog'; import FileListItem from './FileListItem'; -import { FileDeleteFunction, FileDownLoadFunction } from './types'; +import { FileDeleteFunction, FileDownLoadFunction, ShowDeleteButtonFunction } from './types'; +import { AxiosError } from 'axios'; +import { sortBy } from 'lodash'; type Props = { files: AttachmentMetadata[]; fileDownLoadFunction?: FileDownLoadFunction; fileDeleteFunction: FileDeleteFunction; onFileDelete?: () => void; + showDeleteButtonForFile?: ShowDeleteButtonFunction; }; export default function FileList({ @@ -20,15 +23,27 @@ export default function FileList({ fileDownLoadFunction, fileDeleteFunction, onFileDelete, + showDeleteButtonForFile, }: Props) { const { t } = useTranslation(); - const deleteMutation = useMutation(fileDeleteFunction, { - onSuccess() { - onFileDelete && onFileDelete(); + // Sort files in descending order by their createdAt date + const sortedFiles = sortBy(files, (file) => new Date(file.createdAt)).reverse(); + const deleteMutation = useMutation( + fileDeleteFunction, + { + onSuccess() { + if (onFileDelete) { + onFileDelete(); + } + }, }, - }); + ); const [fileToDelete, setFileToDelete] = useState(null); const [showFileDeleteDialog, setShowFileDeleteDialog] = useState(false); + const deleteErrorText: string = + deleteMutation.error?.response?.status === 404 + ? t('common:components:fileUpload:deleteError:fileNotFound') + : t('common:components:fileUpload:deleteError:serverError'); function handleFileDelete(file: AttachmentMetadata) { setFileToDelete(file); @@ -50,17 +65,22 @@ export default function FileList({ deleteMutation.reset(); } + function closeDeleteErrorDialog() { + deleteMutation.reset(); + } + return ( <> - - {files.map((file) => { + + {sortedFiles.map((file) => { return ( ); })} @@ -80,7 +100,7 @@ export default function FileList({ variant="danger" /> - {/* Attachment delete success notification */} + {/* File delete success notification */} {deleteMutation.isSuccess && ( )} + + {/* File remove error dialog */} + ); } diff --git a/src/common/components/fileUpload/FileListItem.module.scss b/src/common/components/fileUpload/FileListItem.module.scss index e668800f9..f4430780a 100644 --- a/src/common/components/fileUpload/FileListItem.module.scss +++ b/src/common/components/fileUpload/FileListItem.module.scss @@ -1,23 +1,27 @@ @import 'src/assets/styles/layout.scss'; .fileListItem { - // display: flex; - // align-items: flex-start; + display: flex; + align-items: flex-start; + flex-wrap: wrap; background-color: var(--color-info-light); border-bottom: 2px dotted var(--color-coat-of-arms); - box-sizing: border-box; max-width: 100%; width: 600px; + row-gap: var(--spacing-2-xs); + column-gap: var(--spacing-xs); + margin-bottom: var(--spacing-xs); padding: var(--spacing-s) var(--spacing-2-xs); - flex-wrap: wrap; +} - // @include respond-above(l) { - // flex-wrap: nowrap; - // } +.fileListItemNameContainer { + display: flex; + flex-wrap: nowrap; + flex: 1; + align-items: flex-start; } .fileListItemName { - // word-break: break-word; hyphens: auto; } diff --git a/src/common/components/fileUpload/FileListItem.tsx b/src/common/components/fileUpload/FileListItem.tsx index 7324b5aea..8fa7b9092 100644 --- a/src/common/components/fileUpload/FileListItem.tsx +++ b/src/common/components/fileUpload/FileListItem.tsx @@ -1,4 +1,4 @@ -import { Box, Flex } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; import { format, isToday } from 'date-fns'; import { fi } from 'date-fns/locale'; import { Button, IconCross, IconDocument, IconDownload } from 'hds-react'; @@ -7,58 +7,61 @@ import { AttachmentMetadata } from '../../types/attachment'; import FileDownloadLink from '../fileDownloadLink/FileDownloadLink'; import Text from '../text/Text'; import styles from './FileListItem.module.scss'; -import { FileDownLoadFunction } from './types'; +import { FileDownLoadFunction, ShowDeleteButtonFunction } from './types'; type Props = { - fileMetadata: AttachmentMetadata; + file: AttachmentMetadata; onDeleteFile: (file: AttachmentMetadata) => void; fileDownLoadFunction?: FileDownLoadFunction; deletingFile?: boolean; + showDeleteButtonForFile?: ShowDeleteButtonFunction; }; export default function FileListItem({ - fileMetadata, + file, onDeleteFile, fileDownLoadFunction, deletingFile = false, + showDeleteButtonForFile, }: Props) { const { t } = useTranslation(); - const showDeleteButton = true; - const dateAdded = new Date(fileMetadata.createdAt); + const dateAdded = new Date(file.createdAt); const dateAddedText = isToday(dateAdded) ? t('form:labels:today') : format(dateAdded, 'd.M.yyyy', { locale: fi }); + const showDeleteButton = showDeleteButtonForFile === undefined || showDeleteButtonForFile(file); + + function deleteFile() { + onDeleteFile(file); + } return ( - +
  • - +
    {fileDownLoadFunction === undefined ? ( - {fileMetadata.fileName} + {file.fileName} ) : ( <> fileDownLoadFunction(fileMetadata)} + queryKey={['attachmentContent', file.id]} + queryFunction={() => fileDownLoadFunction(file)} /> )} - - +
    + {t('form:labels:added')} {dateAddedText} {showDeleteButton && ( @@ -68,13 +71,13 @@ export default function FileListItem({ variant="supplementary" size="small" theme="black" - onClick={() => onDeleteFile(fileMetadata)} - data-testid={`delete-${fileMetadata.id}`} + onClick={deleteFile} + data-testid={`delete-${file.id}`} isLoading={deletingFile} > {t('common:buttons:remove')} )} -
    +
  • ); } diff --git a/src/common/components/fileUpload/FileUpload.test.tsx b/src/common/components/fileUpload/FileUpload.test.tsx index b36b46a3c..1af55ab14 100644 --- a/src/common/components/fileUpload/FileUpload.test.tsx +++ b/src/common/components/fileUpload/FileUpload.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { rest } from 'msw'; -import { act, fireEvent, render, screen, waitFor } from '../../../testUtils/render'; +import { act, fireEvent, render, screen, waitFor, within } from '../../../testUtils/render'; import api from '../../../domain/api/api'; import FileUpload from './FileUpload'; import { server } from '../../../domain/mocks/test-server'; +import { AttachmentMetadata } from '../../types/attachment'; +import { deleteAttachment } from '../../../domain/application/attachments'; async function uploadAttachment({ id, file }: { id: number; file: File }) { const { data } = await api.post(`/hakemukset/${id}/liitteet`, { @@ -129,3 +131,86 @@ test('Should upload files when user drops them into drag-and-drop area', async ( expect(screen.queryByText('3/3 tiedosto(a) tallennettu')).toBeInTheDocument(); }); }); + +test('Should list added files', async () => { + const fileNameA = 'TestFile1.pdf'; + const fileA: AttachmentMetadata = { + id: '4f08ce3f-a0de-43c6-8ccc-9fe93822ed18', + fileName: fileNameA, + createdByUserId: 'b9a58f4c-f5fe-11ec-997f-0a580a800284', + createdAt: '2023-07-04T12:07:52.324684Z', + }; + const fileNameB = 'TestFile2.png'; + const fileB: AttachmentMetadata = { + id: 'd8e43d5a-ac40-448b-ad35-92120a7f2377', + fileName: fileNameB, + createdByUserId: 'b9a58f4c-f5fe-11ec-997f-0a580a800284', + createdAt: new Date().toISOString(), + }; + const files: AttachmentMetadata[] = [fileA, fileB]; + render( + Promise.resolve()} + existingAttachments={files} + />, + ); + const { getAllByRole } = within(screen.getByTestId('file-upload-list')); + const fileListItems = getAllByRole('listitem'); + expect(fileListItems.length).toBe(2); + + const fileItemA = fileListItems.find((i) => i.innerHTML.includes(fileNameA)); + const { getByText: getByTextInA } = within(fileItemA!); + expect(getByTextInA('Lisätty 4.7.2023')).toBeInTheDocument(); + + const fileItemB = fileListItems.find((i) => i.innerHTML.includes(fileNameB)); + const { getByText: getByTextInB } = within(fileItemB!); + expect(getByTextInB('Lisätty tänään')).toBeInTheDocument(); +}); + +test('Should be able to delete file', async () => { + const fileNameA = 'TestFile1.jpg'; + const fileA: AttachmentMetadata = { + id: '4f08ce3f-a0de-43c6-8ccc-9fe93822ed54', + fileName: fileNameA, + createdByUserId: 'b9a58f4c-f5fe-11ec-997f-0a580a800284', + createdAt: '2023-07-04T12:07:52.324684Z', + }; + const fileNameB = 'TestFile2.pdf'; + const fileB: AttachmentMetadata = { + id: 'd8e43d5a-ac40-448b-ad35-92120a7f2367', + fileName: fileNameB, + createdByUserId: 'b9a58f4c-f5fe-11ec-997f-0a580a800284', + createdAt: '2023-09-06T12:09:55.324684Z', + }; + const files: AttachmentMetadata[] = [fileA, fileB]; + const { user } = render( + deleteAttachment({ applicationId: 1, attachmentId: file?.id })} + existingAttachments={files} + />, + ); + const { getAllByRole } = within(screen.getByTestId('file-upload-list')); + const fileListItems = getAllByRole('listitem'); + const fileItemA = fileListItems.find((i) => i.innerHTML.includes(fileNameA)); + const { getByRole: getByRoleInA } = within(fileItemA!); + await user.click(getByRoleInA('button', { name: 'Poista' })); + const { getByRole: getByRoleInDialog, getByText: getByTextInDialog } = within( + screen.getByRole('dialog'), + ); + + expect( + getByTextInDialog(`Haluatko varmasti poistaa liitetiedoston ${fileNameA}`), + ).toBeInTheDocument(); + await user.click(getByRoleInDialog('button', { name: 'Poista' })); + expect(screen.getByText(`Liitetiedosto ${fileNameA} poistettu`)).toBeInTheDocument(); +}); diff --git a/src/common/components/fileUpload/FileUpload.tsx b/src/common/components/fileUpload/FileUpload.tsx index 287691129..3a8e288f5 100644 --- a/src/common/components/fileUpload/FileUpload.tsx +++ b/src/common/components/fileUpload/FileUpload.tsx @@ -1,17 +1,17 @@ import { useEffect, useRef, useState } from 'react'; import { useMutation } from 'react-query'; +import { Flex } from '@chakra-ui/react'; 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'; import FileList from './FileList'; -import { FileDeleteFunction, FileDownLoadFunction } from './types'; +import { FileDeleteFunction, FileDownLoadFunction, ShowDeleteButtonFunction } from './types'; function useDragAndDropFiles() { const ref = useRef(null); @@ -74,6 +74,7 @@ type Props = { fileDownLoadFunction?: FileDownLoadFunction; fileDeleteFunction: FileDeleteFunction; onFileDelete?: () => void; + showDeleteButtonForFile?: ShowDeleteButtonFunction; }; export default function FileUpload({ @@ -89,6 +90,7 @@ export default function FileUpload({ fileDownLoadFunction, fileDeleteFunction, onFileDelete, + showDeleteButtonForFile, }: Props) { const { t } = useTranslation(); const locale = useLocale(); @@ -137,6 +139,13 @@ export default function FileUpload({ uploadFiles(filesToUpload); } + function handleFileDelete() { + setNewFiles([]); + if (onFileDelete) { + onFileDelete(); + } + } + return (
    @@ -173,7 +182,8 @@ export default function FileUpload({ files={existingAttachments} fileDownLoadFunction={fileDownLoadFunction} fileDeleteFunction={fileDeleteFunction} - onFileDelete={onFileDelete} + onFileDelete={handleFileDelete} + showDeleteButtonForFile={showDeleteButtonForFile} />
    ); diff --git a/src/common/components/fileUpload/types.ts b/src/common/components/fileUpload/types.ts index fb42ff241..4dfd49eeb 100644 --- a/src/common/components/fileUpload/types.ts +++ b/src/common/components/fileUpload/types.ts @@ -3,3 +3,5 @@ import { AttachmentMetadata } from '../../types/attachment'; export type FileDownLoadFunction = (file: AttachmentMetadata) => Promise; export type FileDeleteFunction = (file: AttachmentMetadata | null) => Promise; + +export type ShowDeleteButtonFunction = (file: AttachmentMetadata) => boolean; diff --git a/src/domain/mocks/handlers.ts b/src/domain/mocks/handlers.ts index 3143121f5..3d1051821 100644 --- a/src/domain/mocks/handlers.ts +++ b/src/domain/mocks/handlers.ts @@ -237,4 +237,8 @@ export const handlers = [ rest.post(`${apiUrl}/hakemukset/:id/liitteet`, async (req, res, ctx) => { return res(ctx.delay(), ctx.status(200)); }), + + rest.delete(`${apiUrl}/hakemukset/:id/liitteet/:attachmentId`, async (req, res, ctx) => { + return res(ctx.status(200)); + }), ]; diff --git a/src/locales/fi.json b/src/locales/fi.json index 80272ec05..910f73476 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -51,7 +51,12 @@ }, "fileUpload": { "loadingText": "Tallennetaan tiedostoja", - "successNotification": "{{successful}}/{{total}} tiedosto(a) tallennettu" + "successNotification": "{{successful}}/{{total}} tiedosto(a) tallennettu", + "deleteError": { + "title": "Tiedoston poistamisessa tapahtui virhe", + "fileNotFound": "Tiedostoa, jonka yritit poistaa ei löydy (virhe 404). Yritä myöhemmin uudelleen.", + "serverError": "Palvelimeen ei saada yhteyttä, eikä valittua tiedostoa saada poistettua. Yritä myöhemmin uudelleen." + } } }, "error": "Tapahtui virhe. Yritä uudestaan.",