feat(frontend): add delete functionality (#19)
* feat(frontend): add delete functionality * test(frontend): coverage for deletion buttons
This commit is contained in:
parent
d7f2ea51c8
commit
5010da18f5
8 changed files with 181 additions and 25 deletions
|
@ -7,7 +7,8 @@ import MuiDeleteIcon from "@mui/icons-material/Delete"
|
|||
import MuiDownloadIcon from "@mui/icons-material/Download"
|
||||
import MuiIconButton from "@mui/material/IconButton"
|
||||
|
||||
import { useFileDetails } from "../queries/files"
|
||||
import { useLocationContext } from "../contexts/LocationContext"
|
||||
import { useFileDetails, useFileMutations } from "../queries/files"
|
||||
|
||||
interface FileDetailsProps {
|
||||
itemId: string
|
||||
|
@ -15,6 +16,8 @@ interface FileDetailsProps {
|
|||
|
||||
function FileDetails({ itemId }: FileDetailsProps) {
|
||||
const { isLoading, data } = useFileDetails(itemId)
|
||||
const { deleteFile } = useFileMutations()
|
||||
const { navigate } = useLocationContext()
|
||||
|
||||
if (isLoading || !data) return null
|
||||
|
||||
|
@ -22,10 +25,6 @@ function FileDetails({ itemId }: FileDetailsProps) {
|
|||
console.log("download click")
|
||||
}
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
console.log("delete click")
|
||||
}
|
||||
|
||||
const currentFileDetails = data
|
||||
|
||||
return (
|
||||
|
@ -52,7 +51,10 @@ function FileDetails({ itemId }: FileDetailsProps) {
|
|||
</MuiIconButton>
|
||||
<MuiIconButton
|
||||
aria-label="delete item"
|
||||
onClick={() => handleDeleteClick()}
|
||||
onClick={async () => {
|
||||
await deleteFile(itemId)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<MuiDeleteIcon />
|
||||
</MuiIconButton>
|
||||
|
|
|
@ -14,7 +14,7 @@ import MuiTypography from "@mui/material/Typography"
|
|||
import { byteSizeToUnits } from "../utils"
|
||||
import { useLocationContext } from "../contexts/LocationContext"
|
||||
import { useAsyncTaskContext } from "../contexts/AsyncTaskContext"
|
||||
import { type FileData } from "../queries/files"
|
||||
import { type FileData, useFileMutations } from "../queries/files"
|
||||
|
||||
interface FileListProps {
|
||||
data: Array<FileData>
|
||||
|
@ -80,16 +80,13 @@ function FileListItem({
|
|||
function FileList({ data }: FileListProps) {
|
||||
const { tasks } = useAsyncTaskContext()
|
||||
const { navigate } = useLocationContext()
|
||||
|
||||
const { deleteFile } = useFileMutations()
|
||||
const onClickHandler = (uid: string) => {
|
||||
navigate(`/item/${uid}/`)
|
||||
}
|
||||
const onDownloadHandler = () => {
|
||||
console.log("download")
|
||||
}
|
||||
const onDeleteHandler = () => {
|
||||
console.log("delete")
|
||||
}
|
||||
|
||||
const dataWithPlaceholders = [...tasks, ...data]
|
||||
|
||||
|
@ -103,7 +100,9 @@ function FileList({ data }: FileListProps) {
|
|||
onClickHandler("id" in itemData ? itemData.id : "")
|
||||
}
|
||||
onDownloadHandler={onDownloadHandler}
|
||||
onDeleteHandler={onDeleteHandler}
|
||||
onDeleteHandler={() =>
|
||||
"id" in itemData ? deleteFile(itemData.id) : null
|
||||
}
|
||||
key={`file list item ${itemData.filename}`}
|
||||
/>
|
||||
))
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
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.
|
||||
*/
|
||||
|
@ -48,7 +70,7 @@ async function uploadFile(file: File) {
|
|||
return response.json
|
||||
}
|
||||
|
||||
export { useOwnFileList, useFileDetails, uploadFile }
|
||||
export { useOwnFileList, useFileDetails, useFileMutations, uploadFile }
|
||||
|
||||
// Types
|
||||
export { FileData }
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
interface RequestOptions {
|
||||
method: string
|
||||
body: FormData | string
|
||||
body?: FormData | string
|
||||
}
|
||||
|
||||
interface Response<ResponseSchema> {
|
||||
|
|
92
frontend/tests/FileDetails.test.tsx
Normal file
92
frontend/tests/FileDetails.test.tsx
Normal 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("/")
|
||||
})
|
||||
})
|
|
@ -1,12 +1,25 @@
|
|||
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 * as requestUtil from "../src/queries/requestUtils"
|
||||
import { type FileData } from "../src/queries/files"
|
||||
|
||||
describe("FileList", () => {
|
||||
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 = [
|
||||
|
@ -60,5 +73,31 @@ describe("FileList", () => {
|
|||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
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 NavigationBar from "../src/components/NavigationBar"
|
||||
import * as requestUtil from "../src/queries/requestUtils"
|
||||
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("Upload functionality", () => {
|
||||
test("Renders the upload button", () => {
|
||||
|
|
|
@ -6,6 +6,8 @@ import AsyncTaskContext, {
|
|||
type AsyncTask,
|
||||
} from "../src/contexts/AsyncTaskContext"
|
||||
import LocationContext from "../src/contexts/LocationContext"
|
||||
import * as requestUtil from "../src/queries/requestUtils"
|
||||
import { type FileData } from "../src/queries/files"
|
||||
|
||||
interface ContextInitialValues {
|
||||
asyncTaskContext: Array<AsyncTask>
|
||||
|
@ -32,3 +34,9 @@ export function renderWithContexts(
|
|||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyMakeRequestMock<Schema>(
|
||||
impl: typeof requestUtil.default<Schema>,
|
||||
) {
|
||||
return jest.spyOn(requestUtil, "default").mockImplementation(impl)
|
||||
}
|
||||
|
|
Reference in a new issue