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 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>
|
||||||
|
|
|
@ -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}`}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
method: string
|
method: string
|
||||||
body: FormData | string
|
body?: FormData | string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Response<ResponseSchema> {
|
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 { 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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
Reference in a new issue