diff --git a/frontend/src/components/FileUploader.css b/frontend/src/components/FileUploader.css index d14ce5f7ea..90d6710490 100644 --- a/frontend/src/components/FileUploader.css +++ b/frontend/src/components/FileUploader.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/FileUploader.js b/frontend/src/components/FileUploader.js index 6c68e5eb05..6239e4bc19 100644 --- a/frontend/src/components/FileUploader.js +++ b/frontend/src/components/FileUploader.js @@ -5,89 +5,110 @@ // 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 (
-

- Drag and drop your files here - {' '} -
- or -
- Browse files -

+ + {errorMessage + && ( + + {errorMessage} + + )}
); } 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 }) => ( +
+ + + + + + + + + {files.map((file, index) => ( - - -
- {file.name} -
+
+ + + + + + + ))} - + +
+ Name + + Size + + Status + - return ( - <> - - +
+ {file.originalFileName} + + {`${(file.fileSize / 1000).toFixed(1)} KB`} + + {file.status} + - - +
+ { files.length === 0 && ( +

No files uploaded

+ )} +
+); +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 ( + <> + + + ); }; 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 = { diff --git a/frontend/src/components/Navigator/index.js b/frontend/src/components/Navigator/index.js index 1b7f0c2650..a0cf8a44b0 100644 --- a/frontend/src/components/Navigator/index.js +++ b/frontend/src/components/Navigator/index.js @@ -29,6 +29,7 @@ function Navigator({ additionalData, onSave, autoSaveInterval, + reportId, }) { const [formData, updateFormData] = useState(initialData); const [errorMessage, updateErrorMessage] = useState(); @@ -131,6 +132,7 @@ function Navigator({ submitted, onFormSubmit, additionalData, + reportId, )} {!page.review && ( @@ -142,7 +144,7 @@ function Navigator({ onSubmit={handleSubmit(onContinue)} className="smart-hub--form-large" > - {page.render(hookForm, additionalData)} + {page.render(hookForm, additionalData, reportId)} @@ -170,6 +172,7 @@ Navigator.propTypes = { currentPage: PropTypes.string.isRequired, autoSaveInterval: PropTypes.number, additionalData: PropTypes.shape({}), + reportId: PropTypes.node.isRequired, }; Navigator.defaultProps = { diff --git a/frontend/src/components/__tests__/FileUploader.js b/frontend/src/components/__tests__/FileUploader.js index e9f3ca4fc6..c7b52c3082 100644 --- a/frontend/src/components/__tests__/FileUploader.js +++ b/frontend/src/components/__tests__/FileUploader.js @@ -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); @@ -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 = ; + const ui = ; 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 = ; + 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( {}} files={[file('fileOne'), file('fileTwo')]} />); + render( {}} 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(); - const fileOne = screen.getByText('fileOne'); - fireEvent.click(fileOne.nextSibling); + render(); + const fileTwo = screen.getByText('fileTwo'); + fireEvent.click(fileTwo.parentNode.lastChild.firstChild); - expect(mockOnChange).toHaveBeenCalledWith([file('fileTwo')]); + expect(mockOnChange).toHaveBeenCalledWith([file('fileOne')]); }); }); diff --git a/frontend/src/fetchers/File.js b/frontend/src/fetchers/File.js new file mode 100644 index 0000000000..a1a2f176b4 --- /dev/null +++ b/frontend/src/fetchers/File.js @@ -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; +} diff --git a/frontend/src/fetchers/__tests__/File.js b/frontend/src/fetchers/__tests__/File.js new file mode 100644 index 0000000000..65767d18f9 --- /dev/null +++ b/frontend/src/fetchers/__tests__/File.js @@ -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); + }); +}); diff --git a/frontend/src/pages/ActivityReport/Pages/topicsResources.js b/frontend/src/pages/ActivityReport/Pages/topicsResources.js index 86bc9df499..f37f74e543 100644 --- a/frontend/src/pages/ActivityReport/Pages/topicsResources.js +++ b/frontend/src/pages/ActivityReport/Pages/topicsResources.js @@ -15,6 +15,7 @@ import { topics } from '../constants'; const TopicsResources = ({ register, control, + reportId, }) => ( <> @@ -50,13 +51,13 @@ const TopicsResources = ({ />
- + ( - + )} />
@@ -69,7 +70,7 @@ const TopicsResources = ({ defaultValue={[]} control={control} render={({ onChange, value }) => ( - + )} /> @@ -80,6 +81,7 @@ TopicsResources.propTypes = { register: PropTypes.func.isRequired, // eslint-disable-next-line react/forbid-prop-types control: PropTypes.object.isRequired, + reportId: PropTypes.node.isRequired, }; const sections = [ @@ -95,7 +97,7 @@ const sections = [ anchor: 'resources', items: [ { label: 'Resources used', name: 'resourcesUsed' }, - { label: 'Other resources', name: 'other-resources', path: 'name' }, + { label: 'Other resources', name: 'otherResources', path: 'name' }, ], }, { @@ -113,12 +115,13 @@ export default { path: 'topics-resources', sections, review: false, - render: (hookForm) => { + render: (hookForm, additionalData, reportId) => { const { control, register } = hookForm; return ( ); }, diff --git a/frontend/src/pages/ActivityReport/index.js b/frontend/src/pages/ActivityReport/index.js index c99ebf3dc3..34feb5c0e4 100644 --- a/frontend/src/pages/ActivityReport/index.js +++ b/frontend/src/pages/ActivityReport/index.js @@ -163,6 +163,7 @@ function ActivityReport({ match }) {

New activity report for Region 14

{ + await queryInterface.addColumn('Files', + 'fileSize', + { + type: Sequelize.INTEGER, + allowNull: false, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn('Files', 'fileSize'); + }, +}; diff --git a/src/models/activityReport.js b/src/models/activityReport.js index 191e3855d2..7ad103d90a 100644 --- a/src/models/activityReport.js +++ b/src/models/activityReport.js @@ -26,7 +26,8 @@ export default (sequelize, DataTypes) => { as: 'collaborators', }); ActivityReport.belongsTo(models.Region, { foreignKey: 'regionId', as: 'region' }); - ActivityReport.hasMany(models.File, { foreignKey: 'activityReportId', as: 'activityFiles' }); + ActivityReport.hasMany(models.File, { foreignKey: 'activityReportId', as: 'attachments' }); + ActivityReport.hasMany(models.File, { foreignKey: 'activityReportId', as: 'otherResources' }); ActivityReport.belongsToMany(models.Goal, { through: models.ActivityReportGoal, foreignKey: 'activityReportId', diff --git a/src/models/file.js b/src/models/file.js index 109e8939a7..2ab2a43b8a 100644 --- a/src/models/file.js +++ b/src/models/file.js @@ -32,6 +32,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.ENUM('ATTACHMENT', 'RESOURCE'), allowNull: false, }, + // File size in bytes + fileSize: { + type: DataTypes.INTEGER, + allowNull: false, + }, }, { sequelize, modelName: 'File', diff --git a/src/routes/files/handlers.js b/src/routes/files/handlers.js index b3a12a1c2b..7e34e7a513 100644 --- a/src/routes/files/handlers.js +++ b/src/routes/files/handlers.js @@ -21,6 +21,7 @@ export const createFileMetaData = async ( s3FileName, reportId, attachmentType, + fileSize, ) => { const newFile = { activityReportId: reportId, @@ -28,6 +29,7 @@ export const createFileMetaData = async ( attachmentType, key: s3FileName, status: 'UPLOADING', + fileSize, }; let file; try { @@ -64,8 +66,11 @@ export default async function uploadHandler(req, res) { res.status(400).send({ error: 'file required' }); return; } - const { path, originalFilename } = files.file[0]; + const { path, originalFilename, size } = files.file[0]; const { reportId, attachmentType } = fields; + if (!size) { + res.status(400).send({ error: 'fileSize required' }); + } if (!reportId) { res.status(400).send({ error: 'reportId required' }); return; @@ -85,7 +90,13 @@ export default async function uploadHandler(req, res) { return; } fileName = `${uuidv4()}.${type.ext}`; - metadata = await createFileMetaData(originalFilename, fileName, reportId, attachmentType[0]); + metadata = await createFileMetaData( + originalFilename, + fileName, + reportId, + attachmentType[0], + size, + ); } catch (err) { await handleErrors(req, res, err, logContext); return; diff --git a/src/services/activityReports.js b/src/services/activityReports.js index a66d25c9d8..8501fe9aad 100644 --- a/src/services/activityReports.js +++ b/src/services/activityReports.js @@ -6,6 +6,7 @@ import { ActivityReportCollaborator, sequelize, ActivityRecipient, + File, Grant, Grantee, NonGrantee, @@ -150,6 +151,28 @@ export function activityReportById(activityReportId) { attributes: ['id', 'name'], as: 'collaborators', }, + { + model: File, + where: { + attachmentType: 'ATTACHMENT', + status: { + [Op.ne]: 'UPLOAD_FAILED', + }, + }, + as: 'attachments', + required: false, + }, + { + model: File, + where: { + attachmentType: 'RESOURCE', + status: { + [Op.ne]: 'UPLOAD_FAILED', + }, + }, + as: 'otherResources', + required: false, + }, ], }); } @@ -157,7 +180,12 @@ export function activityReportById(activityReportId) { export async function createOrUpdate(newActivityReport, report) { let savedReport; const { - collaborators, activityRecipients, goals, ...updatedFields + goals, + collaborators, + activityRecipients, + attachments, + otherResources, + ...updatedFields } = newActivityReport; await sequelize.transaction(async (transaction) => { if (report) {