fix: complete vendor media cancel and preview flow

This commit is contained in:
Alexandre Brandizzi 2026-09-23 05:32:18 -03:00
parent 0e8328f91d
commit 9d310afa75
3 changed files with 105 additions and 24 deletions

View file

@ -42,7 +42,9 @@ export function isRetryableMediaUploadError(error: unknown): boolean {
}
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
return (
typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
);
}
export async function computeSha256Hex(buffer: ArrayBuffer): Promise<string> {

View file

@ -34,10 +34,11 @@ function formatBytes(sizeBytes: number): string {
return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`;
}
function viewMediaItem(item: VendorMediaItem, blob: Blob): void {
function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window): void {
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
previewWindow.document.title = item.originalFileName;
previewWindow.addEventListener("pagehide", () => URL.revokeObjectURL(url), { once: true });
previewWindow.location.replace(url);
}
function VendorMediaItems({
@ -148,6 +149,7 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
const [loadState, setLoadState] = useState<MediaLoadState>({ loading: true });
const [progress, setProgress] = useState<number>();
const [uploadCanceled, setUploadCanceled] = useState(false);
const [mediaActionError, setMediaActionError] = useState<string>();
const load = useCallback(async () => {
setLoadState({ loading: true });
@ -217,14 +219,21 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
const selectFile = (file: File | undefined) => startUpload(file ?? null);
const handleView = async (item: VendorMediaItem) => {
const previewWindow = window.open("about:blank", "_blank");
if (!previewWindow) {
setMediaActionError("Allow pop-ups to view this media file.");
return;
}
previewWindow.opener = null;
previewWindow.document.title = item.originalFileName;
previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`;
setMediaActionError(undefined);
try {
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
viewMediaItem(item, blob);
viewMediaItem(item, blob, previewWindow);
} catch {
setLoadState((current) => ({
...current,
error: "Unable to open the media file. Please try again.",
}));
previewWindow.close();
setMediaActionError("Unable to open the media file. Check your connection and try again.");
}
};
@ -260,6 +269,9 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
)}
<VendorMediaItems items={items} onView={(item) => void handleView(item)} />
{Boolean(mediaActionError) && (
<VendorPortalFeedback tone="error">{mediaActionError}</VendorPortalFeedback>
)}
<VendorUploadProgress
pending={uploadMutation.isPending}
progress={progress}

View file

@ -2,10 +2,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as mediaUploadEngine from "@/api/media-upload-engine";
import type { MediaUploadEndpointSet } from "@/api/media-upload-client";
import { MediaUploadNetworkError } from "@/api/media-upload-client";
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal";
import type {
VendorMediaItem,
VendorPortalMedia,
} from "@/domain/vendor-portal/types/vendor-portal";
import { VendorMediaSection } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
const MEDIA: VendorPortalMedia = {
@ -116,18 +120,29 @@ describe("Vendor media controls", () => {
});
it("aborts the active vendor upload from the visible Cancel control", async () => {
const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal?.addEventListener(
"abort",
() => reject(new DOMException("Aborted", "AbortError")),
{
once: true,
},
);
}),
);
const cancel = vi.fn().mockResolvedValue(undefined);
const endpoints: MediaUploadEndpointSet = {
create: vi.fn().mockResolvedValue({
uploadId: "upload-cancel",
state: "Created",
chunkSizeBytes: 5_242_880,
sizeBytes: 5,
receivedChunks: [],
expiresAt: "2026-09-24T00:00:00Z",
}),
putChunk: vi.fn(
({ signal }) =>
new Promise((_resolve, reject) => {
const abort = () => reject(new DOMException("Aborted", "AbortError"));
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
}),
),
commit: vi.fn(),
getStatus: vi.fn(),
cancel,
};
vi.mocked(vendorPortalMediaApi.endpoints).mockReturnValue(endpoints);
renderVendorMedia();
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
@ -140,10 +155,62 @@ describe("Vendor media controls", () => {
await waitFor(() =>
expect(screen.getByRole("button", { name: "Cancel upload" })).toBeInTheDocument(),
);
const signal = upload.mock.calls[0]?.[0].signal;
fireEvent.click(screen.getByRole("button", { name: "Cancel upload" }));
await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument());
expect(signal?.aborted).toBe(true);
expect(endpoints.putChunk).toHaveBeenCalledWith(
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
}),
);
await waitFor(() => expect(cancel).toHaveBeenCalledWith("upload-cancel"));
});
it("opens media from the View click before fetching and revokes its object URL on close", async () => {
const item: VendorMediaItem = {
id: 14,
originalFileName: "photo.jpg",
contentType: "image/jpeg",
sizeBytes: 5,
kind: "Photo",
category: "Extra",
scanStatus: "Passed",
createdAt: "2026-09-23T00:00:00Z",
canView: true,
};
vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue({
...MEDIA,
items: [item],
});
const addEventListener = vi.fn();
const previewWindow = {
opener: window,
closed: false,
document: { title: "", body: { textContent: "" } },
location: { replace: vi.fn() },
addEventListener,
close: vi.fn(),
} as unknown as Window;
const open = vi.spyOn(window, "open").mockReturnValue(previewWindow);
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:vendor-media");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
const fetchContent = vi
.spyOn(vendorPortalMediaApi, "fetchContent")
.mockImplementation(async (_token, _dispatchId, mediaId) => {
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
expect(mediaId).toBe(item.id);
return new Blob(["image"], { type: item.contentType });
});
renderVendorMedia();
fireEvent.click(await screen.findByRole("button", { name: "View" }));
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith("vendor-token", 9, item.id));
expect(createObjectURL).toHaveBeenCalledOnce();
expect(previewWindow.document.title).toBe(item.originalFileName);
expect(previewWindow.location.replace).toHaveBeenCalledWith("blob:vendor-media");
const pageHide = addEventListener.mock.calls[0]?.[1] as EventListener | undefined;
pageHide?.(new Event("pagehide"));
expect(revokeObjectURL).toHaveBeenCalledWith("blob:vendor-media");
});
});