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 }) => (
+
+
+
+
+
+ Name
+ |
+
+ Size
+ |
+
+ Status
+ |
+ |
- return (
- <>
-
-
+
+
+
{files.map((file, index) => (
-
-
-
- {file.name}
-
+
+
+ {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) {