Skip to content

Commit

Permalink
HAI-2066 File list for upload component
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
markohaarni committed Oct 31, 2023
1 parent 3f58bab commit 49dda13
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 55 deletions.
13 changes: 6 additions & 7 deletions src/common/components/fileDownloadLink/FileDownloadLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,21 @@ function FileDownloadLink({
}: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [fileUrl, setFileUrl] = useState('');
const [fileUrl, setFileUrl] = useState(queryClient.getQueryData<string>(queryKey) ?? '');
const linkRef = useRef<HTMLAnchorElement>(null);
const [errorText, setErrorText] = useState<string | null>(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<HTMLAnchorElement, MouseEvent>) {
setErrorText(null);
const cachedUrl = queryClient.getQueryData<string>(queryKey);
if (cachedUrl) {
setFileUrl(cachedUrl);
} else {
if (!fileUrl) {
event.preventDefault();
setLoading(true);
try {
Expand All @@ -49,6 +47,7 @@ function FileDownloadLink({
});
setFileUrl(url);
setLoading(false);
setFileFetched(true);
} catch (error) {
setLoading(false);
setErrorText(t('common:error'));
Expand Down
50 changes: 41 additions & 9 deletions src/common/components/fileUpload/FileList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,44 @@ 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({
files,
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<void, AxiosError, AttachmentMetadata | null, unknown>(
fileDeleteFunction,
{
onSuccess() {
if (onFileDelete) {
onFileDelete();
}
},
},
});
);
const [fileToDelete, setFileToDelete] = useState<AttachmentMetadata | null>(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);
Expand All @@ -50,17 +65,22 @@ export default function FileList({
deleteMutation.reset();
}

function closeDeleteErrorDialog() {
deleteMutation.reset();
}

return (
<>
<Box as="ul" tabIndex={-1} marginTop="var(--spacing-m)">
{files.map((file) => {
<Box as="ul" tabIndex={-1} marginTop="var(--spacing-m)" data-testid="file-upload-list">
{sortedFiles.map((file) => {
return (
<FileListItem
fileMetadata={file}
file={file}
key={file.id}
onDeleteFile={handleFileDelete}
fileDownLoadFunction={fileDownLoadFunction}
deletingFile={fileToDelete?.id === file.id && deleteMutation.isLoading}
showDeleteButtonForFile={showDeleteButtonForFile}
/>
);
})}
Expand All @@ -80,7 +100,7 @@ export default function FileList({
variant="danger"
/>

{/* Attachment delete success notification */}
{/* File delete success notification */}
{deleteMutation.isSuccess && (
<Notification
position="top-right"
Expand All @@ -97,6 +117,18 @@ export default function FileList({
})}
</Notification>
)}

{/* File remove error dialog */}
<ConfirmationDialog
title={t('common:components:fileUpload:deleteError:title')}
description={deleteErrorText}
isOpen={deleteMutation.isError}
close={closeDeleteErrorDialog}
mainAction={closeDeleteErrorDialog}
mainBtnLabel={t('common:ariaLabels:closeButtonLabelText')}
variant="primary"
showSecondaryButton={false}
/>
</>
);
}
20 changes: 12 additions & 8 deletions src/common/components/fileUpload/FileListItem.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
55 changes: 29 additions & 26 deletions src/common/components/fileUpload/FileListItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<Flex
as="li"
tabIndex={-1}
className={styles.fileListItem}
alignItems="flex-start"
gridColumnGap="var(--spacing-xs)"
gridRowGap="var(--spacing-2-xs)"
mb="var(--spacing-xs)"
>
<li tabIndex={-1} className={styles.fileListItem}>
<IconDocument aria-hidden />
<Flex wrap="nowrap" flex="1" alignItems="flex-start">
<div className={styles.fileListItemNameContainer}>
{fileDownLoadFunction === undefined ? (
<Text tag="p" className={styles.fileListItemName}>
{fileMetadata.fileName}
{file.fileName}
</Text>
) : (
<>
<FileDownloadLink
linkText={fileMetadata.fileName}
fileName={fileMetadata.fileName}
linkText={file.fileName}
fileName={file.fileName}
linkTextStyles={styles.fileListItemName}
queryKey={['attachmentContent', fileMetadata.id]}
queryFunction={() => fileDownLoadFunction(fileMetadata)}
queryKey={['attachmentContent', file.id]}
queryFunction={() => fileDownLoadFunction(file)}
/>
<IconDownload aria-hidden className={styles.fileListItemDownloadIcon} />
</>
)}
</Flex>
<Box as="p" color="var(--color-black-60)" className="text-sm">
</div>
<Box
as="p"
color="var(--color-black-60)"
className="text-sm"
marginRight={showDeleteButtonForFile ? 0 : 'var(--spacing-xs)'}
>
{t('form:labels:added')} {dateAddedText}
</Box>
{showDeleteButton && (
Expand All @@ -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')}
</Button>
)}
</Flex>
</li>
);
}
87 changes: 86 additions & 1 deletion src/common/components/fileUpload/FileUpload.test.tsx
Original file line number Diff line number Diff line change
@@ -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`, {
Expand Down Expand Up @@ -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(
<FileUpload
id="test-file-input"
label="Choose a file"
multiple
dragAndDrop
uploadFunction={uploadFunction}
fileDeleteFunction={() => 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(
<FileUpload
id="test-file-input"
label="Choose a file"
multiple
dragAndDrop
uploadFunction={uploadFunction}
fileDeleteFunction={(file) => 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();
});
Loading

0 comments on commit 49dda13

Please sign in to comment.