Skip to content

Commit

Permalink
Merge pull request #133 from adhocteam/cm-wire-frontend-file-upload
Browse files Browse the repository at this point in the history
Add frontend file upload
  • Loading branch information
dcmcand authored Feb 3, 2021
2 parents e27e7df + 95cdef9 commit c5a035f
Show file tree
Hide file tree
Showing 13 changed files with 267 additions and 84 deletions.
20 changes: 20 additions & 0 deletions frontend/src/components/FileUploader.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,23 @@
.fa-stack {
width: 1em;
}

.files-table {
width: 100%;
border-collapse: collapse;
/* border: 1px solid #979797; */
}

.files-table--thead th, .files-table td {
padding: .5rem;
text-align: left;
font-size: .9rem;
}
.files-table--container {
border: solid 1px #979797;
min-height: 8rem;
}

.files-table--empty {
text-align: center;
}
183 changes: 118 additions & 65 deletions frontend/src/components/FileUploader.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,112 +5,165 @@
// react-dropzone examples all use prop spreading. Disabling the eslint no prop spreading
// rules https://github.com/react-dropzone/react-dropzone
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useDropzone } from 'react-dropzone';

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes, faCircle } from '@fortawesome/free-solid-svg-icons';
import { Tag, Button, Grid } from '@trussworks/react-uswds';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { Button, Alert } from '@trussworks/react-uswds';
import uploadFile from '../fetchers/File';

import './FileUploader.css';

function Dropzone(props) {
const { onChange } = props;
const onDrop = (e) => {
onChange(e);
const { onChange, id, reportId } = props;
const [errorMessage, setErrorMessage] = useState();
const onDrop = async (e) => {
if (props.reportId === 'new') {
setErrorMessage('Cannot save attachments without a Grantee or Non-Grantee selected');
return;
}
let attachmentType;
if (id === 'attachments') {
attachmentType = 'ATTACHMENT';
} else if (id === 'otherResources') {
attachmentType = 'RESOURCE';
}
const upload = async (file) => {
try {
const data = new FormData();
data.append('reportId', reportId);
data.append('attachmentType', attachmentType);
data.append('file', file);
await uploadFile(data);
} catch (error) {
setErrorMessage(`${file.name} failed to upload`);
// eslint-disable-next-line no-console
console.log(error);
return null;
}
setErrorMessage(null);
return {
key: file.name, originalFileName: file.name, fileSize: file.size, status: 'UPLOADED',
};
};
const newFiles = e.map((file) => upload(file));
Promise.all(newFiles).then((values) => {
onChange(values);
});
};
const { getRootProps, getInputProps } = useDropzone({ onDrop });

// I tried moving these styles to a css file and applying a class to the container
// and span. The styles were not being applied, it seems like the Dropzone library
// is messing with the styles somewhere
const containerStyle = {
maxWidth: '21rem',
height: '8rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderStyle: 'dashed',
borderWidth: '0.125rem',
borderColor: '#979797',
};

const textStyle = {
textAlign: 'center',
fontSize: '16px',
};

const linkStyle = {
cursor: 'pointer',
color: 'blue',
textDecoration: 'underline',
};

return (
<div
{...getRootProps()}
style={containerStyle}
>
<input {...getInputProps()} />
<p style={textStyle}>
<b>Drag and drop your files here</b>
{' '}
<br />
or
<br />
<span style={linkStyle}>Browse files</span>
</p>
<button type="button" className="usa-button">
Browse files
</button>
{errorMessage
&& (
<Alert type="error" slim noIcon className="smart-hub--save-alert">
{errorMessage}
</Alert>
)}
</div>
);
}

Dropzone.propTypes = {
onChange: PropTypes.func.isRequired,
reportId: PropTypes.node.isRequired,
id: PropTypes.string.isRequired,
};

const FileUploader = ({ onChange, files }) => {
const onFilesAdded = (newFiles) => {
onChange([...files, ...newFiles]);
};

const onFileRemoved = (removedFileIndex) => {
onChange(files.filter((f, index) => (index !== removedFileIndex)));
};
const FileTable = ({ onFileRemoved, files }) => (
<div className="files-table--container margin-top-2">
<table className="files-table">
<thead className="files-table--thead" bgcolor="#F8F8F8">
<tr>
<th width="50%">
Name
</th>
<th width="20%">
Size
</th>
<th width="20%">
Status
</th>
<th width="10%" aria-label="remove file" />

return (
<>
<Dropzone onChange={onFilesAdded} />
<Grid row gap className="margin-top-2">
</tr>
</thead>
<tbody>
{files.map((file, index) => (
<Grid key={file.name} col={6} className="margin-top-1">
<Tag className="smart-hub--file-tag">
<div className="smart-hub--file-tag-text">
{file.name}
</div>
<tr key={file.key} id={`files-table-row-${index}`}>
<td className="files-table--file-name">
{file.originalFileName}
</td>
<td>
{`${(file.fileSize / 1000).toFixed(1)} KB`}
</td>
<td>
{file.status}
</td>
<td>
<Button
role="button"
className="smart-hub--file-tag-button"
unstyled
aria-label="remove file"
onClick={() => { onFileRemoved(index); }}
>
<span className="fa-stack fa-sm">
<FontAwesomeIcon className="fa-stack-1x" color="white" icon={faCircle} />
<FontAwesomeIcon className="fa-stack-1x" color="black" icon={faTimes} />
<span className="fa-sm">
<FontAwesomeIcon color="black" icon={faTrash} />
</span>
</Button>
</Tag>
</Grid>
</td>

</tr>

))}
</Grid>
</tbody>
</table>
{ files.length === 0 && (
<p className="files-table--empty">No files uploaded</p>
)}
</div>
);
FileTable.propTypes = {
onFileRemoved: PropTypes.func.isRequired,
files: PropTypes.arrayOf(PropTypes.object),
};
FileTable.defaultProps = {
files: [],
};
const FileUploader = ({
onChange, files, reportId, id,
}) => {
const onFilesAdded = (newFiles) => {
onChange([...files, ...newFiles]);
};

const onFileRemoved = (removedFileIndex) => {
onChange(files.filter((f, index) => (index !== removedFileIndex)));
};

return (
<>
<Dropzone id={id} reportId={reportId} onChange={onFilesAdded} />
<FileTable onFileRemoved={onFileRemoved} files={files} />

</>
);
};

FileUploader.propTypes = {
onChange: PropTypes.func.isRequired,
files: PropTypes.arrayOf(PropTypes.instanceOf(File)),
files: PropTypes.arrayOf(PropTypes.object),
reportId: PropTypes.node.isRequired,
id: PropTypes.string.isRequired,
};

FileUploader.defaultProps = {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/Navigator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function Navigator({
additionalData,
onSave,
autoSaveInterval,
reportId,
}) {
const [formData, updateFormData] = useState(initialData);
const [errorMessage, updateErrorMessage] = useState();
Expand Down Expand Up @@ -131,6 +132,7 @@ function Navigator({
submitted,
onFormSubmit,
additionalData,
reportId,
)}
{!page.review
&& (
Expand All @@ -142,7 +144,7 @@ function Navigator({
onSubmit={handleSubmit(onContinue)}
className="smart-hub--form-large"
>
{page.render(hookForm, additionalData)}
{page.render(hookForm, additionalData, reportId)}
<Button type="submit" disabled={!isValid}>Continue</Button>
</Form>
</Container>
Expand Down Expand Up @@ -170,6 +172,7 @@ Navigator.propTypes = {
currentPage: PropTypes.string.isRequired,
autoSaveInterval: PropTypes.number,
additionalData: PropTypes.shape({}),
reportId: PropTypes.node.isRequired,
};

Navigator.defaultProps = {
Expand Down
31 changes: 23 additions & 8 deletions frontend/src/components/__tests__/FileUploader.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import React from 'react';
import {
render, fireEvent, waitFor, act, screen,
} from '@testing-library/react';
import * as fileFetcher from '../../fetchers/File';

import FileUploader from '../FileUploader';

describe('FileUploader', () => {
jest.spyOn(fileFetcher, 'default').mockImplementation(() => Promise.resolve());
const dispatchEvt = (node, type, data) => {
const event = new Event(type, { bubbles: true });
Object.assign(event, data);
Expand All @@ -29,33 +31,46 @@ describe('FileUploader', () => {
},
});

const file = (name) => new File([''], name, { type: 'text/html' });
const file = (name) => ({ originalFileName: name, fileSize: 2000, status: 'Uploaded' });

it('onDrop adds calls the onChange method', async () => {
const mockOnChange = jest.fn();
const data = mockData([file('file')]);
const ui = <FileUploader onChange={mockOnChange} files={[]} />;
const ui = <FileUploader reportId="3" id="attachment" onChange={mockOnChange} files={[]} />;
const { container, rerender } = render(ui);
const dropzone = container.querySelector('div');

dispatchEvt(dropzone, 'drop', data);
await dispatchEvt(dropzone, 'drop', data);
await flushPromises(rerender, ui);

expect(mockOnChange).toHaveBeenCalled();
});

it('checks that onDrop does not run if reportId is new', async () => {
const mockOnChange = jest.fn();
const data = mockData([file('file')]);
const ui = <FileUploader reportId="new" id="attachment" onChange={mockOnChange} files={[]} />;
const { container, rerender } = render(ui);
const dropzone = container.querySelector('div');

await dispatchEvt(dropzone, 'drop', data);
await flushPromises(rerender, ui);

expect(mockOnChange).not.toHaveBeenCalled();
});

it('files are properly displayed', () => {
render(<FileUploader onChange={() => {}} files={[file('fileOne'), file('fileTwo')]} />);
render(<FileUploader reportId="new" id="attachment" onChange={() => {}} files={[file('fileOne'), file('fileTwo')]} />);
expect(screen.getByText('fileOne')).toBeVisible();
expect(screen.getByText('fileTwo')).toBeVisible();
});

it('files can be removed', () => {
const mockOnChange = jest.fn();
render(<FileUploader onChange={mockOnChange} files={[file('fileOne'), file('fileTwo')]} />);
const fileOne = screen.getByText('fileOne');
fireEvent.click(fileOne.nextSibling);
render(<FileUploader reportId="new" id="attachment" onChange={mockOnChange} files={[file('fileOne'), file('fileTwo')]} />);
const fileTwo = screen.getByText('fileTwo');
fireEvent.click(fileTwo.parentNode.lastChild.firstChild);

expect(mockOnChange).toHaveBeenCalledWith([file('fileTwo')]);
expect(mockOnChange).toHaveBeenCalledWith([file('fileOne')]);
});
});
15 changes: 15 additions & 0 deletions frontend/src/fetchers/File.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import join from 'url-join';

const activityReportUrl = join('/', 'api', 'files');

export default async function uploadFile(data) {
const res = await fetch(activityReportUrl, {
method: 'POST',
credentials: 'same-origin',
body: data,
});
if (!res.ok) {
throw new Error(res.statusText);
}
return res;
}
14 changes: 14 additions & 0 deletions frontend/src/fetchers/__tests__/File.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import fetchMock from 'fetch-mock';
import join from 'url-join';
import uploadFile from '../File';

const activityReportUrl = join('/', 'api', 'files');
const fakeFile = new File(['testing'], 'test.txt');

describe('File fetcher', () => {
it('test that the file gets uploaded', async () => {
fetchMock.mock(activityReportUrl, 200);
const res = await uploadFile(fakeFile);
expect(res.status).toBe(200);
});
});
Loading

0 comments on commit c5a035f

Please sign in to comment.