feat(frontend): add delete functionality (#19)

* feat(frontend): add delete functionality

* test(frontend): coverage for deletion buttons
This commit is contained in:
Marc 2023-08-17 18:59:22 -04:00 committed by GitHub
parent d7f2ea51c8
commit 5010da18f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 181 additions and 25 deletions

View file

@ -7,7 +7,8 @@ import MuiDeleteIcon from "@mui/icons-material/Delete"
import MuiDownloadIcon from "@mui/icons-material/Download" import MuiDownloadIcon from "@mui/icons-material/Download"
import MuiIconButton from "@mui/material/IconButton" import MuiIconButton from "@mui/material/IconButton"
import { useFileDetails } from "../queries/files" import { useLocationContext } from "../contexts/LocationContext"
import { useFileDetails, useFileMutations } from "../queries/files"
interface FileDetailsProps { interface FileDetailsProps {
itemId: string itemId: string
@ -15,6 +16,8 @@ interface FileDetailsProps {
function FileDetails({ itemId }: FileDetailsProps) { function FileDetails({ itemId }: FileDetailsProps) {
const { isLoading, data } = useFileDetails(itemId) const { isLoading, data } = useFileDetails(itemId)
const { deleteFile } = useFileMutations()
const { navigate } = useLocationContext()
if (isLoading || !data) return null if (isLoading || !data) return null
@ -22,10 +25,6 @@ function FileDetails({ itemId }: FileDetailsProps) {
console.log("download click") console.log("download click")
} }
const handleDeleteClick = () => {
console.log("delete click")
}
const currentFileDetails = data const currentFileDetails = data
return ( return (
@ -52,7 +51,10 @@ function FileDetails({ itemId }: FileDetailsProps) {
</MuiIconButton> </MuiIconButton>
<MuiIconButton <MuiIconButton
aria-label="delete item" aria-label="delete item"
onClick={() => handleDeleteClick()} onClick={async () => {
await deleteFile(itemId)
navigate("/")
}}
> >
<MuiDeleteIcon /> <MuiDeleteIcon />
</MuiIconButton> </MuiIconButton>

View file

@ -14,7 +14,7 @@ import MuiTypography from "@mui/material/Typography"
import { byteSizeToUnits } from "../utils" import { byteSizeToUnits } from "../utils"
import { useLocationContext } from "../contexts/LocationContext" import { useLocationContext } from "../contexts/LocationContext"
import { useAsyncTaskContext } from "../contexts/AsyncTaskContext" import { useAsyncTaskContext } from "../contexts/AsyncTaskContext"
import { type FileData } from "../queries/files" import { type FileData, useFileMutations } from "../queries/files"
interface FileListProps { interface FileListProps {
data: Array<FileData> data: Array<FileData>
@ -80,16 +80,13 @@ function FileListItem({
function FileList({ data }: FileListProps) { function FileList({ data }: FileListProps) {
const { tasks } = useAsyncTaskContext() const { tasks } = useAsyncTaskContext()
const { navigate } = useLocationContext() const { navigate } = useLocationContext()
const { deleteFile } = useFileMutations()
const onClickHandler = (uid: string) => { const onClickHandler = (uid: string) => {
navigate(`/item/${uid}/`) navigate(`/item/${uid}/`)
} }
const onDownloadHandler = () => { const onDownloadHandler = () => {
console.log("download") console.log("download")
} }
const onDeleteHandler = () => {
console.log("delete")
}
const dataWithPlaceholders = [...tasks, ...data] const dataWithPlaceholders = [...tasks, ...data]
@ -103,7 +100,9 @@ function FileList({ data }: FileListProps) {
onClickHandler("id" in itemData ? itemData.id : "") onClickHandler("id" in itemData ? itemData.id : "")
} }
onDownloadHandler={onDownloadHandler} onDownloadHandler={onDownloadHandler}
onDeleteHandler={onDeleteHandler} onDeleteHandler={() =>
"id" in itemData ? deleteFile(itemData.id) : null
}
key={`file list item ${itemData.filename}`} key={`file list item ${itemData.filename}`}
/> />
)) ))

View file

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import makeRequest from "./requestUtils" import makeRequest from "./requestUtils"
@ -33,6 +33,28 @@ function useFileDetails(fileId: string) {
}) })
} }
/*
* `useFileMutations` provides helpers that trigger API interactions
* with the backend to modify files stored in the system.
*/
function useFileMutations(): {
deleteFile: (fileId: string) => Promise<FileData>
} {
const queryClient = useQueryClient()
const deleteFile = async (fileId: string): Promise<FileData> => {
const response = await makeRequest<FileData>(
`http://localhost:8000/files/${fileId}`,
{ method: "DELETE" },
)
queryClient.invalidateQueries({ queryKey: ["file-list"] })
return response.json
}
return { deleteFile }
}
/* /*
* Uploads a file. * Uploads a file.
*/ */
@ -48,7 +70,7 @@ async function uploadFile(file: File) {
return response.json return response.json
} }
export { useOwnFileList, useFileDetails, uploadFile } export { useOwnFileList, useFileDetails, useFileMutations, uploadFile }
// Types // Types
export { FileData } export { FileData }

View file

@ -7,7 +7,7 @@
interface RequestOptions { interface RequestOptions {
method: string method: string
body: FormData | string body?: FormData | string
} }
interface Response<ResponseSchema> { interface Response<ResponseSchema> {

View file

@ -0,0 +1,92 @@
import { act } from "@testing-library/react"
import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { type UseQueryResult } from "@tanstack/react-query"
import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import FileDetails from "../src/components/FileDetails"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
import * as fileQueries from "../src/queries/files"
import * as locationContextUtils from "../src/contexts/LocationContext"
describe("FileDetails", () => {
const mockItem = {
title: "Item 1",
filename: "item1.txt",
size: 1,
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
}
test("Clicking the delete button fires request to delete file", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: 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 deleteButton = getByLabelText("delete item")
await user.click(deleteButton)
expect(spy.mock.calls.length).toEqual(1)
const deleteRequest = spy.mock.calls[0]
expect(deleteRequest[0]).toMatch(new RegExp(`\/files\/${mockItem.id}\$`))
expect(deleteRequest[1]?.method).toEqual("DELETE")
})
test("Clicking the delete button redirects to the file list after success", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: mockItem,
}),
)
jest
.spyOn(fileQueries, "useFileDetails")
.mockReturnValue({ data: mockItem, isLoading: false } as UseQueryResult<
FileData,
unknown
>)
const navigateMock = jest.fn().mockImplementation((a: string) => {})
jest.spyOn(locationContextUtils, "useLocationContext").mockReturnValue({
location: {
path: "",
label: null,
params: {},
},
navigate: navigateMock,
})
const user = userEvent.setup()
const { getByLabelText, debug, rerender } = render(
<FileDetails itemId={mockItem.id} />,
)
const deleteButton = getByLabelText("delete item")
await user.click(deleteButton)
expect(navigateMock.mock.calls.length).toEqual(1)
const navigateCall = navigateMock.mock.calls[0]
expect(navigateCall[0]).toEqual("/")
})
})

View file

@ -1,12 +1,25 @@
import { within } from "@testing-library/dom" import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { renderWithContexts as render } from "./helpers" import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import FileList from "../src/components/FileList" import FileList from "../src/components/FileList"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
describe("FileList", () => { describe("FileList", () => {
const mockItems = [ const mockItems = [
{ title: "Item 1", filename: "item1.txt", size: 1, id: "123" }, {
{ title: "Item 2", filename: "item2.txt", size: 1, id: "456" }, title: "Item 1",
filename: "item1.txt",
size: 1,
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
},
{
title: "Item 2",
filename: "item2.txt",
size: 1,
id: "b61bf93d-a9db-473e-822e-a65003b1b7e4",
},
] ]
const mockAsyncTasks = [ const mockAsyncTasks = [
@ -60,5 +73,31 @@ describe("FileList", () => {
getByLabelText(action) getByLabelText(action)
}, },
) )
test("Clicking the delete button fires request to delete file", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: mockItems[0],
}),
)
const user = userEvent.setup()
const { getByLabelText, debug } = render(
<FileList data={[mockItems[0]]} />,
)
const deleteButton = getByLabelText("delete item")
await user.click(deleteButton)
expect(spy.mock.calls.length).toEqual(1)
const deleteRequest = spy.mock.calls[0]
expect(deleteRequest[0]).toMatch(
new RegExp(`\/files\/${mockItems[0].id}\$`),
)
expect(deleteRequest[1]?.method).toEqual("DELETE")
})
}) })
}) })

View file

@ -1,17 +1,11 @@
import { within } from "@testing-library/dom" import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event" import userEvent from "@testing-library/user-event"
import { renderWithContexts as render } from "./helpers" import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import NavigationBar from "../src/components/NavigationBar" import NavigationBar from "../src/components/NavigationBar"
import * as requestUtil from "../src/queries/requestUtils" import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files" import { type FileData } from "../src/queries/files"
function applyMakeRequestMock<Schema>(
impl: typeof requestUtil.default<Schema>,
) {
return jest.spyOn(requestUtil, "default").mockImplementation(impl)
}
describe("NavigationBar", () => { describe("NavigationBar", () => {
describe("Upload functionality", () => { describe("Upload functionality", () => {
test("Renders the upload button", () => { test("Renders the upload button", () => {

View file

@ -6,6 +6,8 @@ import AsyncTaskContext, {
type AsyncTask, type AsyncTask,
} from "../src/contexts/AsyncTaskContext" } from "../src/contexts/AsyncTaskContext"
import LocationContext from "../src/contexts/LocationContext" import LocationContext from "../src/contexts/LocationContext"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
interface ContextInitialValues { interface ContextInitialValues {
asyncTaskContext: Array<AsyncTask> asyncTaskContext: Array<AsyncTask>
@ -32,3 +34,9 @@ export function renderWithContexts(
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
export function applyMakeRequestMock<Schema>(
impl: typeof requestUtil.default<Schema>,
) {
return jest.spyOn(requestUtil, "default").mockImplementation(impl)
}