feat(frontend): file downloads (#23)

* feat(frontend): file downloads flow

* refactor(frontend): downloadFile to fetches hook

* test(frontend): coverage for FileList downloads

* test(frontend): downloads through FileDetails

* test(frontend): ensure that DOM clicks are mocked in jest

* docs(frontend): add details on file fetches hook
This commit is contained in:
Marc 2023-08-18 19:01:24 -04:00 committed by GitHub
parent b77574e596
commit 35b582394c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 126 additions and 12 deletions

View file

@ -8,7 +8,11 @@ import MuiDownloadIcon from "@mui/icons-material/Download"
import MuiIconButton from "@mui/material/IconButton"
import { useLocationContext } from "../contexts/LocationContext"
import { useFileDetails, useFileMutations } from "../queries/files"
import {
useFileDetails,
useFileMutations,
useFileFetches,
} from "../queries/files"
interface FileDetailsProps {
itemId: string
@ -18,13 +22,10 @@ function FileDetails({ itemId }: FileDetailsProps) {
const { isLoading, data } = useFileDetails(itemId)
const { deleteFile } = useFileMutations()
const { navigate } = useLocationContext()
const { downloadFile } = useFileFetches()
if (isLoading || !data) return null
const handleDownloadClick = () => {
console.log("download click")
}
const currentFileDetails = data
return (
@ -45,7 +46,9 @@ function FileDetails({ itemId }: FileDetailsProps) {
<>
<MuiIconButton
aria-label="download item"
onClick={() => handleDownloadClick()}
onClick={async () => {
await downloadFile(itemId, currentFileDetails.filename)
}}
>
<MuiDownloadIcon />
</MuiIconButton>

View file

@ -14,7 +14,11 @@ import MuiTypography from "@mui/material/Typography"
import { byteSizeToUnits } from "../utils"
import { useLocationContext } from "../contexts/LocationContext"
import { useAsyncTaskContext } from "../contexts/AsyncTaskContext"
import { type FileData, useFileMutations } from "../queries/files"
import {
type FileData,
useFileMutations,
useFileFetches,
} from "../queries/files"
interface FileListProps {
data: Array<FileData>
@ -81,12 +85,11 @@ function FileList({ data }: FileListProps) {
const { tasks } = useAsyncTaskContext()
const { navigate } = useLocationContext()
const { deleteFile } = useFileMutations()
const { downloadFile } = useFileFetches()
const onClickHandler = (uid: string) => {
navigate(`/item/${uid}/`)
}
const onDownloadHandler = () => {
console.log("download")
}
const dataWithPlaceholders = [...tasks, ...data]
@ -99,7 +102,9 @@ function FileList({ data }: FileListProps) {
onClickHandler={() =>
onClickHandler("id" in itemData ? itemData.id : "")
}
onDownloadHandler={onDownloadHandler}
onDownloadHandler={async () => {
"id" in itemData ? downloadFile(itemData.id, itemData.filename) : null
}}
onDeleteHandler={() =>
"id" in itemData ? deleteFile(itemData.id) : null
}

View file

@ -54,6 +54,35 @@ function useFileMutations(): {
return { deleteFile }
}
/*
* Hook providing callable async functions implementing one-off
* fetches on the files API.
*
* Returns:
* downloadFile: Triggers a file download.
*
*/
function useFileFetches(): {
downloadFile: (fileId: string, fileName: string) => Promise<void>
} {
/*
* Downloading the file is done by fetching the binary blob from the
* API and creating a "virtual" anchor element that we programmatically
* click. This is a hack to trigger a file download from a non-file URL.
*/
const downloadFile = async (fileId: string, fileName: string) => {
const response = await axiosWithDefaults.get(`/files/${fileId}/content/`)
const virtualAnchor = document.createElement("a")
virtualAnchor.href = URL.createObjectURL(
new Blob([response.data], { type: "application/octet-stream" }),
)
virtualAnchor.download = fileName
virtualAnchor.click()
}
return { downloadFile }
}
/*
* Uploads a file.
*/
@ -69,7 +98,13 @@ async function uploadFile(file: File) {
return response.data
}
export { useOwnFileList, useFileDetails, useFileMutations, uploadFile }
export {
useOwnFileList,
useFileDetails,
useFileMutations,
useFileFetches,
uploadFile,
}
// Types
export { FileData }

View file

@ -16,6 +16,41 @@ describe("FileDetails", () => {
size: 1,
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
}
test("Clicking the download button trigger a file download", async () => {
// FIXME: Validating file downloads is ... tricky. The current interaction with dynamically created DOM
// elements is not visible by jest.
const expectedUrlPattern = new RegExp(`/files/${mockItem.id}/content/$`)
const axiosMock = getAxiosMockAdapter()
axiosMock.onGet(expectedUrlPattern).reply(200, mockItem)
jest
.spyOn(fileQueries, "useFileDetails")
.mockReturnValue({ data: mockItem, isLoading: false } as UseQueryResult<
FileData,
unknown
>)
const user = userEvent.setup()
const { getByLabelText, debug, rerender } = render(
<FileDetails itemId={mockItem.id} />,
)
const downloadButton = getByLabelText("download item")
await user.click(downloadButton)
const downloadRequests = axiosMock.history.get
expect(downloadRequests.length).toEqual(1)
const downloadRequest = downloadRequests[0]
expect(downloadRequest.url).toMatch(expectedUrlPattern)
})
test("Clicking the delete button fires request to delete file", async () => {
const expectedUrlPattern = new RegExp(`/files/${mockItem.id}/$`)

View file

@ -97,5 +97,34 @@ describe("FileList", () => {
expect(deleteRequest.url).toMatch(expectedUrlPattern)
})
test("Clicking the download button trigger a file download", async () => {
// FIXME: Validating file downloads is ... tricky. The current interaction with dynamically created DOM
// elements is not visible by jest.
const expectedUrlPattern = new RegExp(
`/files/${mockItems[0].id}/content/$`,
)
const axiosMock = getAxiosMockAdapter()
axiosMock.onGet(expectedUrlPattern).reply(200, mockItems[0])
const user = userEvent.setup()
const { getByLabelText, debug } = render(
<FileList data={[mockItems[0]]} />,
)
const downloadButton = getByLabelText("download item")
await user.click(downloadButton)
const getRequests = axiosMock.history.get
expect(getRequests.length).toEqual(1)
const getRequest = getRequests[0]
expect(getRequest.url).toMatch(expectedUrlPattern)
})
})
})

View file

@ -0,0 +1,7 @@
// URL.createObjectURL does not exist in jest-jsdom.
globalThis.URL.createObjectURL = jest
.fn()
.mockImplementation(() => "http://localhost/downloadUrl")
// Clicking DOM objects is not implemented in jest-jsdom.
HTMLAnchorElement.prototype.click = jest.fn()