Skip to content

Commit 194a480

Browse files
feat(portal): add object download cancellation and download toasts (#1042)
* feat(portal): wire up object download cancellation end-to-end * fix(portal): fix squeezed object download progress bar in objects table * feat(portal): show toast when an object download is cancelled * fix(portal): correct download started toast to warn against leaving the view * chore(core): update translations * chore(core): add changeset * fix(portal): fix tests * test(portal): make unmount download test exercise the abort path * fix(portal): don't emit download completion after an aborted transfer * test(portal): assert the cancel transfer control is a labelled button * refactor(portal): extract shared runTransfer helper for download and preview --------- Co-authored-by: Andreas Pfau <andreas.pfau@sap.com>
1 parent 16440c1 commit 194a480

10 files changed

Lines changed: 377 additions & 50 deletions

File tree

.changeset/blue-dingos-beam.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cobaltcore-dev/aurora": minor
3+
---
4+
5+
Ceph object storage: object downloads and previews can now be cancelled while
6+
in flight. The abort signal is propagated from the frontend through the BFF, so
7+
cancelling tears down the request instead of letting it run in the background.
8+
A toast is shown when a download starts, and a warning toast confirms when one
9+
is cancelled.

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Objects/ObjectToastNotifications.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
getObjectMoveErrorToast,
1414
getObjectMetadataUpdatedToast,
1515
getObjectMetadataUpdateErrorToast,
16+
getObjectDownloadStartedToast,
17+
getObjectDownloadCancelledToast,
1618
getObjectDownloadErrorToast,
1719
getVersionRestoredToast,
1820
getVersionRestoreErrorToast,
@@ -159,6 +161,36 @@ describe("ObjectToastNotifications", () => {
159161

160162
// ── Object download ──────────────────────────────────────────────────────────
161163

164+
describe("getObjectDownloadStartedToast", () => {
165+
it("renders correct message content", () => {
166+
renderNotification(getObjectDownloadStartedToast())
167+
expect(screen.getByText("Downloading...")).toBeInTheDocument()
168+
expect(screen.getByText(/Downloading larger files may take a while/)).toBeInTheDocument()
169+
expect(screen.getByText(/the download\(s\) will be interrupted/)).toBeInTheDocument()
170+
expect(screen.getByText(/We are working to improve the user experience/)).toBeInTheDocument()
171+
})
172+
})
173+
174+
describe("getObjectDownloadCancelledToast", () => {
175+
it("returns notification with correct structure", () => {
176+
const toast = getObjectDownloadCancelledToast("documents/file.txt")
177+
expect(toast.message).toBeDefined()
178+
expect(toast.description).toBeDefined()
179+
})
180+
181+
it("renders correct message content", () => {
182+
renderNotification(getObjectDownloadCancelledToast("documents/file.txt"))
183+
expect(screen.getByText("Download Cancelled")).toBeInTheDocument()
184+
expect(screen.getByText('Download of "file.txt" was cancelled.')).toBeInTheDocument()
185+
})
186+
187+
it("extracts the display name from a nested object key", () => {
188+
renderNotification(getObjectDownloadCancelledToast("documents/reports/Q1.pdf"))
189+
expect(screen.getByText(/Q1\.pdf/)).toBeInTheDocument()
190+
expect(screen.queryByText(/documents\/reports/)).not.toBeInTheDocument()
191+
})
192+
})
193+
162194
describe("getObjectDownloadErrorToast", () => {
163195
it("renders correct error content", () => {
164196
renderNotification(getObjectDownloadErrorToast("documents/file.txt", "Network error"))
@@ -188,6 +220,8 @@ describe("ObjectToastNotifications", () => {
188220
getObjectMoveErrorToast("a.txt", "err"),
189221
getObjectMetadataUpdatedToast("a.txt"),
190222
getObjectMetadataUpdateErrorToast("a.txt", "err"),
223+
getObjectDownloadStartedToast(),
224+
getObjectDownloadCancelledToast("a.txt"),
191225
getObjectDownloadErrorToast("a.txt", "err"),
192226
getVersionRestoredToast("a.txt"),
193227
getVersionRestoreErrorToast("a.txt", "err"),

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Objects/ObjectToastNotifications.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,31 @@ export const getObjectMetadataUpdateErrorToast = (
146146

147147
// ── Object download ────────────────────────────────────────────────────────────
148148

149+
// Shown as soon as a download/preview transfer starts. Large objects stream
150+
// through the BFF before anything happens client-side (no native browser
151+
// download progress until the whole file has arrived), so this sets
152+
// expectations up front. It also warns that navigating away aborts the
153+
// in-flight transfer — the unmount cleanup aborts every active controller.
154+
export const getObjectDownloadStartedToast = (): { message: ReactNode } & NotificationOptions => ({
155+
message: <Trans>Downloading...</Trans>,
156+
description: (
157+
<Trans>
158+
Downloading larger files may take a while. Do not leave this view, otherwise the download(s) will be interrupted.
159+
We are working to improve the user experience in the future.
160+
</Trans>
161+
),
162+
})
163+
164+
// A cancelled transfer is a user-initiated outcome, not a failure — confirm it
165+
// so the disappearing progress spinner isn't the only feedback.
166+
export const getObjectDownloadCancelledToast = (objectKey: string): { message: ReactNode } & NotificationOptions => {
167+
const displayName = objectKey.split("/").filter(Boolean).pop() ?? objectKey
168+
return {
169+
message: <Trans>Download Cancelled</Trans>,
170+
description: <Trans>Download of "{displayName}" was cancelled.</Trans>,
171+
}
172+
}
173+
149174
export const getObjectDownloadErrorToast = (
150175
objectKey: string,
151176
errorMessage: string

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Objects/ObjectsTableView.test.tsx

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { render as rtlRender, screen, within, waitFor } from "@testing-library/react"
22
import { I18nProvider } from "@lingui/react"
33
import { i18n } from "@lingui/core"
4-
import { PortalProvider } from "@cloudoperators/juno-ui-components"
4+
import { PortalProvider, toast } from "@cloudoperators/juno-ui-components"
55
import type { ReactNode } from "react"
66
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
77
import userEvent from "@testing-library/user-event"
@@ -32,6 +32,22 @@ vi.mock("@/client/trpcClient", () => ({
3232
},
3333
}))
3434

35+
// The component calls toast(...) directly for the "download started"
36+
// notification (a neutral/base call, not .success/.error/.warning), so the
37+
// mock needs the base export itself to be a spy — not just its sub-methods.
38+
vi.mock("@cloudoperators/juno-ui-components", async (importOriginal) => {
39+
const actual = await importOriginal<typeof import("@cloudoperators/juno-ui-components")>()
40+
const toastFn = Object.assign(vi.fn(), {
41+
success: vi.fn(),
42+
error: vi.fn(),
43+
warning: vi.fn(),
44+
})
45+
return {
46+
...actual,
47+
toast: toastFn,
48+
}
49+
})
50+
3551
// Mock child components to isolate ObjectsTableView
3652
const render = (ui: React.ReactElement) => {
3753
return rtlRender(ui, {
@@ -43,6 +59,12 @@ const render = (ui: React.ReactElement) => {
4359
})
4460
}
4561

62+
// The toast helpers hand toast()/toast.warning() the raw <Trans> elements, not
63+
// rendered strings, so assertions match on the macro-generated `message` prop
64+
// (the source copy) rather than on the element's text.
65+
const transMessage = (message: string | RegExp) =>
66+
expect.objectContaining({ props: expect.objectContaining({ message: expect.stringMatching(message) }) })
67+
4668
vi.mock("@tanstack/react-virtual", () => ({
4769
useVirtualizer: ({ count }: { count: number }) => ({
4870
getVirtualItems: () =>
@@ -268,6 +290,11 @@ describe("ObjectsTableView", () => {
268290

269291
describe("download and preview", () => {
270292
beforeEach(() => {
293+
// toast lives in the module mock factory, so vi.restoreAllMocks() in
294+
// afterEach leaves its call history intact — clear it explicitly or calls
295+
// leak between tests and "not.toHaveBeenCalled()" assertions see them.
296+
vi.mocked(toast).mockClear()
297+
vi.mocked(toast.warning).mockClear()
271298
downloadObjectMutate.mockReset()
272299
// Default: BFF returns text/plain (previewable)
273300
downloadObjectMutate.mockImplementation(async () => {
@@ -306,7 +333,8 @@ describe("ObjectsTableView", () => {
306333
containerName: "test-bucket",
307334
objectKey: "file1.txt",
308335
filename: "file1.txt",
309-
})
336+
}),
337+
expect.objectContaining({ signal: expect.any(AbortSignal) })
310338
)
311339
})
312340

@@ -475,6 +503,126 @@ describe("ObjectsTableView", () => {
475503
await waitFor(() => expect(within(row).queryByText("50%")).not.toBeInTheDocument())
476504
})
477505

506+
it("fires the 'downloading' toast when a download starts", async () => {
507+
const user = userEvent.setup()
508+
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock")
509+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {})
510+
511+
render(<ObjectsTableView {...defaultProps} folders={[]} objects={[mockObjects[0]]} />)
512+
513+
const row = screen.getByTestId("object-row-file1.txt")
514+
await user.click(within(row).getByRole("button", { name: /more/i }))
515+
await user.click(screen.getByTestId("download-action-file1.txt"))
516+
517+
await waitFor(() => expect(toast).toHaveBeenCalledWith(transMessage(/^Downloading/), expect.anything()))
518+
})
519+
520+
it("shows a cancel button while a transfer is in flight, and clicking it aborts the request", async () => {
521+
const user = userEvent.setup()
522+
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock")
523+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {})
524+
525+
let capturedSignal: AbortSignal | undefined
526+
let releaseGate!: () => void
527+
const gate = new Promise<void>((resolve) => {
528+
releaseGate = resolve
529+
})
530+
downloadObjectMutate.mockImplementationOnce(async (_input, options: { signal?: AbortSignal }) => {
531+
capturedSignal = options?.signal
532+
async function* gen() {
533+
yield { chunk: btoa("a"), downloaded: 1, total: 2, contentType: "text/plain", filename: "file1.txt" }
534+
await gate
535+
if (capturedSignal?.aborted) {
536+
const abortError = new Error("Request canceled")
537+
abortError.name = "AbortError"
538+
throw abortError
539+
}
540+
yield { chunk: btoa("b"), downloaded: 2, total: 2 }
541+
}
542+
return gen()
543+
})
544+
545+
render(<ObjectsTableView {...defaultProps} folders={[]} objects={[mockObjects[0]]} />)
546+
const row = screen.getByTestId("object-row-file1.txt")
547+
await user.click(within(row).getByRole("button", { name: /more/i }))
548+
await user.click(screen.getByTestId("download-action-file1.txt"))
549+
550+
const cancelButton = await screen.findByTestId("cancel-transfer-file1.txt")
551+
// The cancel control must be operable without a mouse: a real <button>
552+
// with an accessible name, not a clickable <div>/<span>.
553+
expect(cancelButton.tagName).toBe("BUTTON")
554+
expect(cancelButton).toHaveAccessibleName("Cancel")
555+
556+
await user.click(cancelButton)
557+
558+
expect(capturedSignal?.aborted).toBe(true)
559+
560+
releaseGate()
561+
562+
// A user-initiated cancellation is not an error — it is confirmed with a
563+
// toast, and onDownloadError must not fire for it.
564+
await waitFor(() => expect(screen.queryByTestId("cancel-transfer-file1.txt")).not.toBeInTheDocument())
565+
await waitFor(() =>
566+
expect(toast.warning).toHaveBeenCalledWith(transMessage("Download Cancelled"), expect.anything())
567+
)
568+
expect(defaultProps.onDownloadError).not.toHaveBeenCalled()
569+
})
570+
571+
it("aborts in-flight transfers when the component unmounts", async () => {
572+
// The point of forwarding the AbortSignal to the tRPC call: navigating
573+
// away mid-download must actually stop the request, not just ignore its
574+
// result while it keeps running (and competing for bandwidth/CPU) in
575+
// the background.
576+
const user = userEvent.setup()
577+
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock")
578+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {})
579+
580+
let capturedSignal: AbortSignal | undefined
581+
let releaseGate!: () => void
582+
const gate = new Promise<void>((resolve) => {
583+
releaseGate = resolve
584+
})
585+
// The stream must reject once the signal is aborted, exactly as it does in
586+
// the cancel test — otherwise it completes normally, handleTransferError()
587+
// is never reached, and the assertion below would pass for the wrong
588+
// reason (no error path taken) rather than because the isMounted guard
589+
// suppressed the toast.
590+
downloadObjectMutate.mockImplementationOnce(async (_input, options: { signal?: AbortSignal }) => {
591+
capturedSignal = options?.signal
592+
async function* gen() {
593+
yield { chunk: btoa("a"), downloaded: 1, total: 2, contentType: "text/plain", filename: "file1.txt" }
594+
await gate
595+
if (capturedSignal?.aborted) {
596+
const abortError = new Error("Request canceled")
597+
abortError.name = "AbortError"
598+
throw abortError
599+
}
600+
yield { chunk: btoa("b"), downloaded: 2, total: 2 }
601+
}
602+
return gen()
603+
})
604+
605+
const { unmount } = render(<ObjectsTableView {...defaultProps} folders={[]} objects={[mockObjects[0]]} />)
606+
const row = screen.getByTestId("object-row-file1.txt")
607+
await user.click(within(row).getByRole("button", { name: /more/i }))
608+
await user.click(screen.getByTestId("download-action-file1.txt"))
609+
610+
unmount()
611+
612+
expect(capturedSignal?.aborted).toBe(true)
613+
614+
releaseGate()
615+
616+
// Let the aborted stream reject and handleTransferError() run before
617+
// asserting on what it did (and didn't) do.
618+
await waitFor(() => expect(defaultProps.onDownloadError).not.toHaveBeenCalled())
619+
await new Promise((resolve) => setTimeout(resolve, 0))
620+
621+
// An unmount-triggered abort is not a user-initiated cancellation — the
622+
// component (and the page it lived on) is gone, so no toast is shown.
623+
expect(toast.warning).not.toHaveBeenCalled()
624+
})
625+
478626
it("tracks two concurrent transfers independently — finishing one must not clear the other's state", async () => {
479627
// Regression test for a bug where a single shared piece of state tracked
480628
// the "active" row/downloadId. Starting a second transfer overwrote the

0 commit comments

Comments
 (0)