Skip to content

Commit

Permalink
Merge pull request #859 from raft-tech/epics/89/issues/416/download-f…
Browse files Browse the repository at this point in the history
…iles-frontend

Issue 416: [Frontend] Add a download button to the Data Files view
  • Loading branch information
andrew-jameson authored Jul 8, 2021
2 parents fb2dd9c + c6f86b3 commit 1dafa63
Show file tree
Hide file tree
Showing 11 changed files with 495 additions and 20 deletions.
82 changes: 82 additions & 0 deletions tdrs-frontend/src/actions/reports.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,101 @@
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 } })
}

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) => {
Expand Down
77 changes: 77 additions & 0 deletions tdrs-frontend/src/actions/reports.test.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
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,
setQuarter,
setStt,
setYear,
upload,
download,
getAvailableFileList,
} from './reports'

describe('actions/reports', () => {
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions tdrs-frontend/src/actions/sttList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 40 additions & 3 deletions tdrs-frontend/src/components/FileUpload/FileUpload.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,35 +7,61 @@ 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.'

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
.split(' ')
.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) => {
Expand Down Expand Up @@ -167,6 +193,17 @@ function FileUpload({ section, setLocalAlertState }) {
aria-hidden="false"
data-errormessage={INVALID_FILE_ERROR}
/>
<div style={{ marginTop: '25px' }}>
{hasFile ? (
<Button
className="tanf-file-download-btn"
type="button"
onClick={downloadFile}
>
Download Section {sectionNumber}
</Button>
) : null}
</div>
</div>
)
}
Expand Down
95 changes: 95 additions & 0 deletions tdrs-frontend/src/components/FileUpload/utils.jsx
Original file line number Diff line number Diff line change
@@ -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',
`<div class="${PREVIEW_CLASS}" aria-hidden="true">
<img onerror="this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${GENERIC_PREVIEW_CLASS}")" src="${SPACER_GIF}" alt="" class="${GENERIC_PREVIEW_CLASS_NAME} ${GENERIC_PREVIEW_CLASS}"/>
${fileName}
<div>`
)

// Adds heading above file previews
dropTarget.insertBefore(filePreviewsHeading, instructions)
filePreviewsHeading.innerHTML = `Selected file <span class="usa-file-input__choose">Change file</span>`

// Hides null state content and sets preview heading class
if (filePreviewsHeading) {
instructions.classList.add(HIDDEN_CLASS)
filePreviewsHeading.classList.add(PREVIEW_HEADING_CLASS)
}
return true
}
Loading

0 comments on commit 1dafa63

Please sign in to comment.