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:
parent
b77574e596
commit
35b582394c
6 changed files with 126 additions and 12 deletions
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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}/$`)
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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()
|
Reference in a new issue