mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: complete vendor media cancel and preview flow
This commit is contained in:
parent
0e8328f91d
commit
9d310afa75
3 changed files with 105 additions and 24 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue