diff --git a/tdrs-frontend/src/actions/reports.js b/tdrs-frontend/src/actions/reports.js index 8ddc58787..23c040d03 100644 --- a/tdrs-frontend/src/actions/reports.js +++ b/tdrs-frontend/src/actions/reports.js @@ -1,12 +1,21 @@ import { logErrorToServer } from '../utils/eventLogger' import { v4 as uuidv4 } from 'uuid' +import axios from 'axios' export const SET_FILE = 'SET_FILE' export const CLEAR_FILE = 'CLEAR_FILE' export const SET_FILE_ERROR = 'SET_FILE_ERROR' export const CLEAR_ERROR = 'CLEAR_ERROR' +export const START_FILE_DOWNLOAD = 'START_FILE_DOWNLOAD' +export const FILE_DOWNLOAD_ERROR = 'FILE_DOWNLOAD_ERROR' + +export const FETCH_FILE_LIST = 'FETCH_FILE_LIST' +export const SET_FILE_LIST = 'SET_FILE_LIST' +export const FETCH_FILE_LIST_ERROR = 'FETCH_FILE_LIST_ERROR' +export const DOWNLOAD_DIALOG_OPEN = 'DOWNLOAD_DIALOG_OPEN' + export const clearFile = ({ section }) => (dispatch) => { dispatch({ type: CLEAR_FILE, payload: { section } }) } @@ -14,6 +23,79 @@ export const clearFile = ({ section }) => (dispatch) => { export const clearError = ({ section }) => (dispatch) => { dispatch({ type: CLEAR_ERROR, payload: { section } }) } +/** + Get a list of files that can be downloaded, mainly used to decide + if the download button should be present. +*/ +export const getAvailableFileList = ({ year, quarter = 'Q1' }) => async ( + dispatch +) => { + dispatch({ + type: FETCH_FILE_LIST, + }) + try { + const response = await axios.get(`/mock_api/reports/${year}/${quarter}`, { + responseType: 'json', + }) + dispatch({ + type: SET_FILE_LIST, + payload: { + data: response.data, + }, + }) + } catch (error) { + dispatch({ + type: FETCH_FILE_LIST_ERROR, + payload: { + error, + year, + quarter, + }, + }) + } +} + +export const download = ({ year, quarter = 'Q1', section }) => async ( + dispatch +) => { + try { + if (!year) throw new Error('No year was provided to download action.') + dispatch({ type: START_FILE_DOWNLOAD }) + + const response = await axios.get( + `/mock_api/reports/data-files/${year}/${quarter}/${section}`, + { + responseType: 'blob', + } + ) + const data = response.data + + // Create a link and associate it with the blob returned from the file + // download - this allows us to trigger the file download dialog without + // having to change the route or reload the page. + const url = window.URL.createObjectURL(new Blob([data])) + const link = document.createElement('a') + + link.href = url + link.setAttribute('download', `${year}.${quarter}.${section}.txt`) + + document.body.appendChild(link) + + // Click the link to actually prompt the file download + link.click() + + // Cleanup afterwards to prevent unwanted side effects + document.body.removeChild(link) + dispatch({ type: DOWNLOAD_DIALOG_OPEN }) + } catch (error) { + dispatch({ + type: FILE_DOWNLOAD_ERROR, + payload: { error, year, quarter, section }, + }) + return false + } + return true +} // Main Redux action to add files to the state export const upload = ({ file, section }) => async (dispatch) => { diff --git a/tdrs-frontend/src/actions/reports.test.js b/tdrs-frontend/src/actions/reports.test.js index 17c10fb19..23f748e86 100644 --- a/tdrs-frontend/src/actions/reports.test.js +++ b/tdrs-frontend/src/actions/reports.test.js @@ -1,9 +1,15 @@ +import axios from 'axios' import thunk from 'redux-thunk' import configureStore from 'redux-mock-store' +import { v4 as uuidv4 } from 'uuid' import { SET_SELECTED_YEAR, + START_FILE_DOWNLOAD, SET_FILE, + SET_FILE_LIST, + FILE_DOWNLOAD_ERROR, + DOWNLOAD_DIALOG_OPEN, SET_FILE_ERROR, SET_SELECTED_STT, SET_SELECTED_QUARTER, @@ -11,6 +17,8 @@ import { setStt, setYear, upload, + download, + getAvailableFileList, } from './reports' describe('actions/reports', () => { @@ -55,6 +63,75 @@ describe('actions/reports', () => { }) }) + it('should dispatch OPEN_FILE_DIALOG when a file has been successfully downloaded', async () => { + window.URL.createObjectURL = jest.fn(() => null) + axios.get.mockImplementationOnce(() => + Promise.resolve({ + data: 'Some text', + }) + ) + const store = mockStore() + + await store.dispatch( + download({ + year: 2020, + section: 'Active Case Data', + }) + ) + const actions = store.getActions() + + expect(actions[0].type).toBe(START_FILE_DOWNLOAD) + try { + expect(actions[1].type).toBe(DOWNLOAD_DIALOG_OPEN) + } catch (err) { + throw actions[1].payload.error + } + }) + + it('should dispatch SET_FILE_LIST', async () => { + axios.get.mockImplementationOnce(() => + Promise.resolve({ + data: [ + { + fileName: 'test.txt', + section: 'Active Case Data', + uuid: uuidv4(), + }, + { + fileName: 'testb.txt', + section: 'Closed Case Data', + uuid: uuidv4(), + }, + ], + }) + ) + const store = mockStore() + + await store.dispatch( + getAvailableFileList({ + year: 2020, + }) + ) + const actions = store.getActions() + try { + expect(actions[1].type).toBe(SET_FILE_LIST) + } catch (err) { + throw actions[1].payload.error + } + }) + + it('should dispatch FILE_DOWNLOAD_ERROR if no year is provided to download', async () => { + const store = mockStore() + + await store.dispatch( + download({ + section: 'Active Case Data', + }) + ) + const actions = store.getActions() + expect(actions[0].type).toBe(FILE_DOWNLOAD_ERROR) + }) + it('should dispatch SET_SELECTED_YEAR', async () => { const store = mockStore() diff --git a/tdrs-frontend/src/actions/sttList.js b/tdrs-frontend/src/actions/sttList.js index d3e7f5c25..590fd378e 100644 --- a/tdrs-frontend/src/actions/sttList.js +++ b/tdrs-frontend/src/actions/sttList.js @@ -39,6 +39,7 @@ export const fetchSttList = () => async (dispatch) => { }) if (data) { + // shouldn't this logic be done by the backend serializer? data.forEach((item, i) => { if (item.name === 'Federal Government') { data.splice(i, 1) diff --git a/tdrs-frontend/src/components/FileUpload/FileUpload.jsx b/tdrs-frontend/src/components/FileUpload/FileUpload.jsx index 9239ec49d..47d521b7c 100644 --- a/tdrs-frontend/src/components/FileUpload/FileUpload.jsx +++ b/tdrs-frontend/src/components/FileUpload/FileUpload.jsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react' +import React, { useRef, useEffect } from 'react' import PropTypes from 'prop-types' import { useDispatch, useSelector } from 'react-redux' import fileType from 'file-type/browser' @@ -7,8 +7,13 @@ import { clearFile, SET_FILE_ERROR, upload, + download, } from '../../actions/reports' + +import Button from '../Button' + import createFileInputErrorState from '../../utils/createFileInputErrorState' +import { handlePreview, getTargetClassName } from './utils' const INVALID_FILE_ERROR = 'We can’t process that file format. Please provide a plain text file.' @@ -16,12 +21,17 @@ const INVALID_FILE_ERROR = function FileUpload({ section, setLocalAlertState }) { // e.g. 'Aggregate Case Data' => 'aggregate-case-data' // The set of uploaded files in our Redux state - const files = useSelector((state) => state.reports.files) + const { files, year } = useSelector((state) => state.reports) + const dispatch = useDispatch() // e.g. "1 - Active Case Data" => ["1", "Active Case Data"] const [sectionNumber, sectionName] = section.split(' - ') + const hasFile = files?.some((file) => { + return file.section === sectionName && file.uuid + }) + const selectedFile = files.find((file) => sectionName === file.section) const formattedSectionName = selectedFile.section @@ -29,13 +39,29 @@ function FileUpload({ section, setLocalAlertState }) { .map((word) => word.toLowerCase()) .join('-') - const fileName = selectedFile?.fileName + const targetClassName = getTargetClassName(formattedSectionName) + + const fileName = selectedFile?.fileName || 'report.txt' const hasUploadedFile = Boolean(fileName) const ariaDescription = hasUploadedFile ? `Selected File ${selectedFile?.fileName}. To change the selected file, click this button.` : `Drag file here or choose from folder.` + useEffect(() => { + const trySettingPreview = () => { + const previewState = handlePreview(fileName, targetClassName) + if (!previewState) { + setTimeout(trySettingPreview, 100) + } + } + if (hasFile) trySettingPreview() + }, [hasFile, fileName, targetClassName]) + + const downloadFile = ({ target }) => { + dispatch(clearError({ section: sectionName })) + dispatch(download({ section: sectionName, year })) + } const inputRef = useRef(null) const validateAndUploadFile = (event) => { @@ -167,6 +193,17 @@ function FileUpload({ section, setLocalAlertState }) { aria-hidden="false" data-errormessage={INVALID_FILE_ERROR} /> +
+ {hasFile ? ( + + ) : null} +
) } diff --git a/tdrs-frontend/src/components/FileUpload/utils.jsx b/tdrs-frontend/src/components/FileUpload/utils.jsx new file mode 100644 index 000000000..5bd920807 --- /dev/null +++ b/tdrs-frontend/src/components/FileUpload/utils.jsx @@ -0,0 +1,95 @@ +//This file contains modified versions of code from: +//https://github.com/uswds/uswds/blob/develop/src/js/components/file-input.js +export const PREFIX = 'usa' + +export const PREVIEW_HEADING_CLASS = `${PREFIX}-file-input__preview-heading` +export const PREVIEW_CLASS = `${PREFIX}-file-input__preview` +export const GENERIC_PREVIEW_CLASS_NAME = `${PREFIX}-file-input__preview-image` +export const TARGET_CLASS = `${PREFIX}-file-input__target` + +export const GENERIC_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--generic` + +export const HIDDEN_CLASS = 'display-none' +export const INSTRUCTIONS_CLASS = `${PREFIX}-file-input__instructions` +export const INVALID_FILE_CLASS = 'has-invalid-file' +export const ACCEPTED_FILE_MESSAGE_CLASS = `${PREFIX}-file-input__accepted-files-message` + +const SPACER_GIF = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + +/** + * Removes image previews, we want to start with a clean list every time files are added to the file input + * @param {HTMLElement} dropTarget - target area div that encases the input + * @param {HTMLElement} instructions - text to inform users to drag or select files + */ +const removeOldPreviews = (dropTarget, instructions) => { + const filePreviews = dropTarget.querySelectorAll(`.${PREVIEW_CLASS}`) + const currentPreviewHeading = dropTarget.querySelector( + `.${PREVIEW_HEADING_CLASS}` + ) + const currentErrorMessage = dropTarget.querySelector( + `.${ACCEPTED_FILE_MESSAGE_CLASS}` + ) + + /** + * finds the parent of the passed node and removes the child + * @param {HTMLElement} node + */ + const removeImages = (node) => { + node.parentNode.removeChild(node) + } + + // Remove the heading above the previews + if (currentPreviewHeading) { + currentPreviewHeading.outerHTML = '' + } + + // Remove existing error messages + if (currentErrorMessage) { + currentErrorMessage.outerHTML = '' + dropTarget.classList.remove(INVALID_FILE_CLASS) + } + + // Get rid of existing previews if they exist, show instructions + if (filePreviews !== null) { + if (instructions) { + instructions.classList.remove(HIDDEN_CLASS) + } + Array.prototype.forEach.call(filePreviews, removeImages) + } +} + +export const getTargetClassName = (formattedSectionName) => + `.${TARGET_CLASS} input#${formattedSectionName}` + +export const handlePreview = (fileName, targetClassName) => { + const targetInput = document.querySelector(targetClassName) + const dropTarget = targetInput?.parentElement + + const instructions = dropTarget?.getElementsByClassName(INSTRUCTIONS_CLASS)[0] + const filePreviewsHeading = document.createElement('div') + + // guard against the case that uswd has not yet rendered this + if (!dropTarget || !instructions) return false + + removeOldPreviews(dropTarget, instructions) + + instructions.insertAdjacentHTML( + 'afterend', + `