From 6c765e8b42111c37ff8f9eb199697863e1877a85 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:27:52 -0300 Subject: [PATCH 01/10] feat(work-orders): align Extra Docs accept and 50MB limits --- .../utils/work-order-media-limits.ts | 48 ++++++++++++++----- .../utils/work-order-media-limits.test.ts | 34 ++++++++++++- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 58cc358a..43b5546d 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,9 +1,32 @@ +import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; + /** Backend RequestSizeLimit for media and completion-doc uploads. */ -export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024; +export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000; -const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]); +const MEDIA_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]); -const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); +const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); + +const EXTRA_DOC_MIME_TYPES = new Set([ + ...MEDIA_MIME_TYPES, + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +]); + +const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]); + +function fileExtension(file: File): string { + const name = file.name.toLowerCase(); + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot) : ""; +} + +function isTypeAllowed(file: File, mimes: Set, extensions: Set): boolean { + const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase()); + const extOk = extensions.has(fileExtension(file)); + return mimeOk || extOk; +} export function isWorkOrderFileTooLarge( file: File, @@ -13,23 +36,24 @@ export function isWorkOrderFileTooLarge( } export function isWorkOrderMediaTypeAllowed(file: File): boolean { - const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase()); - const name = file.name.toLowerCase(); - const dot = name.lastIndexOf("."); - const ext = dot >= 0 ? name.slice(dot) : ""; - const extOk = ALLOWED_EXTENSIONS.has(ext); - return mimeOk || extOk; + return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS); +} + +export function isWorkOrderExtraDocTypeAllowed(file: File): boolean { + return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS); } export function workOrderMediaAcceptAttribute(): string { return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; } -export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { - return `${Math.round(maxBytes / (1024 * 1024))} MB`; +export function workOrderExtraDocsAcceptAttribute(): string { + return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`; } -import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; +export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { + return `${Math.round(maxBytes / 1_000_000)} MB`; +} /** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */ export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean { diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 2ae1f827..bc80696e 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; import { canDeleteWorkOrderMedia, + formatMediaSizeLimit, isWorkOrderDocumentsReadOnly, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, isWorkOrderMediaTypeAllowed, MAX_WORK_ORDER_MEDIA_BYTES, + workOrderExtraDocsAcceptAttribute, + workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; describe("work-order-media-limits", () => { @@ -20,7 +24,9 @@ describe("work-order-media-limits", () => { expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false); }); - it("flags files over 30 MB", () => { + it("caps uploads at 50_000_000 bytes and formats decimal MB", () => { + expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000); + expect(formatMediaSizeLimit()).toBe("50 MB"); expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true); expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); }); @@ -36,5 +42,31 @@ describe("work-order-media-limits", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( false, ); + expect(workOrderMediaAcceptAttribute()).not.toContain(".pdf"); + }); + + it("allows Extra Docs PDF DOC DOCX plus media types", () => { + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( + true, + ); + expect( + isWorkOrderExtraDocTypeAllowed({ name: "a.doc", type: "application/msword" } as File), + ).toBe(true); + expect( + isWorkOrderExtraDocTypeAllowed({ + name: "a.docx", + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + } as File), + ).toBe(true); + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe( + true, + ); + expect(isWorkOrderExtraDocTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe( + false, + ); + const accept = workOrderExtraDocsAcceptAttribute(); + expect(accept).toContain(".pdf"); + expect(accept).toContain(".doc"); + expect(accept).toContain(".docx"); }); }); From 807d8ac1a8318cd456fac1b3b5f3fcfa06378104 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:28:37 -0300 Subject: [PATCH 02/10] feat(work-orders): match Extra Docs AAP accept copy --- .../detail/tabs/slide-over-extras-tab.tsx | 12 ++++++------ .../workorders/slide-over-extras-aveta.test.tsx | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a6df25e5..c8728b70 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -20,9 +20,9 @@ import { import { canDeleteWorkOrderMedia, formatMediaSizeLimit, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, - isWorkOrderMediaTypeAllowed, - workOrderMediaAcceptAttribute, + workOrderExtraDocsAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -145,8 +145,8 @@ export function SlideOverExtrasTab({ if (isReadOnly || pending || !files || files.length === 0) return; const accepted: File[] = []; for (const file of Array.from(files)) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); + if (!isWorkOrderExtraDocTypeAllowed(file)) { + toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`); continue; } if (isWorkOrderFileTooLarge(file)) { @@ -208,13 +208,13 @@ export function SlideOverExtrasTab({ {pending ? "Uploading…" : "Upload documents, photos or videos"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file + Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit()} { expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta"); expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument(); }); + + it("shows Extra Docs accept copy with PDF DOC and 50MB", () => { + renderWithProviders( + , + ); + + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50 MB"), + ).toBeInTheDocument(); + const input = document.querySelector('input[type="file"]'); + expect(input?.getAttribute("accept")).toContain(".pdf"); + expect(input?.getAttribute("accept")).toContain(".doc"); + expect(input?.getAttribute("accept")).toContain(".docx"); + }); }); From 4006145ef8bfc1ac7d4964fbfd71b33327625c87 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:28:48 -0300 Subject: [PATCH 03/10] feat(work-orders): keep Photos allowlist media-only at 50MB --- .../media-uploader-dropzone.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx new file mode 100644 index 00000000..2a1ee978 --- /dev/null +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -0,0 +1,25 @@ +import { createRef } from "react"; +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone"; +import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("MediaUploaderDropzone", () => { + it("keeps Photos accept copy media-only at 50 MB", () => { + renderWithProviders( + ()} + pending={false} + onFilesSelected={vi.fn()} + />, + ); + + expect(screen.getByText(/Accepted: JPG, PNG, MP4, MOV · Max 50 MB/)).toBeInTheDocument(); + expect(screen.queryByText(/PDF/)).not.toBeInTheDocument(); + expect(screen.queryByText(/DOC/)).not.toBeInTheDocument(); + const input = document.querySelector('input[type="file"]'); + expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute()); + expect(input?.getAttribute("accept")).not.toContain(".pdf"); + }); +}); From 599e38aae3e1f0293e55b7d267ec5fe06b8b5124 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:30:04 -0300 Subject: [PATCH 04/10] feat(work-orders): add media content API path --- src/api/api-paths.ts | 2 ++ src/test/domain/work-orders/api/work-orders-api.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index d175e6c5..37db2b85 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -37,6 +37,8 @@ export const API_PATHS = { media: (id: string | number) => `workorders/${id}/media`, mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, + mediaContent: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index d383aefc..7fa8ac99 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -22,6 +22,7 @@ vi.mock("@/api/api", () => ({ patch: (...args: unknown[]) => apiPatchFn(...args), post: (...args: unknown[]) => apiPostFn(...args), delete: (...args: unknown[]) => apiDeleteFn(...args), + get: (...args: unknown[]) => apiGetFn(...args), }, })); @@ -895,3 +896,9 @@ describe("workOrdersApi.fetchVendorsDropdown", () => { }); }); }); + +describe("API_PATHS.workOrder.mediaContent", () => { + it("builds the authorized media content path", () => { + expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content"); + }); +}); From edf4a51d4c5ee2bc382124081e32bacfd3066516 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:31:12 -0300 Subject: [PATCH 05/10] feat(work-orders): fetch Extra Docs via authenticated content GET --- .../api/work-order-board-documents-api.ts | 21 ++++++++++ .../work-orders/api/work-orders-api.test.ts | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 97ca0d2b..7b185607 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -199,6 +199,27 @@ export const workOrderBoardDocumentsApi = { throwDocumentHttpError(response, data); }, + getMediaContent: async ( + workOrderId: string | number, + mediaId: string | number, + ): Promise => { + const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), { + credentials: "include", + throwHttpErrors: false, + }); + + if (response.ok) { + return response.blob(); + } + + if (response.status === 404) { + throw new ApiError(mapHttpStatusToMessage(404), 404); + } + + const data = await readResponseJson(response); + throwDocumentHttpError(response, data); + }, + uploadCompletionDoc: async ( workOrderId: string | number, params: { diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 7fa8ac99..ac21b18a 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -9,6 +9,7 @@ const apiPatch = vi.fn(); const apiPatchFn = vi.fn(); const apiPostFn = vi.fn(); const apiDeleteFn = vi.fn(); +const apiGetFn = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), @@ -902,3 +903,40 @@ describe("API_PATHS.workOrder.mediaContent", () => { expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content"); }); }); + +describe("workOrdersApi.getMediaContent", () => { + beforeEach(() => { + apiGetFn.mockReset(); + }); + + it("returns the response blob on 200", async () => { + const blob = new Blob(["pdf-bytes"], { type: "application/pdf" }); + apiGetFn.mockResolvedValue({ + ok: true, + status: 200, + blob: async () => blob, + }); + + const result = await workOrdersApi.getMediaContent(10, 12); + + expect(result).toBe(blob); + expect(apiGetFn).toHaveBeenCalledWith( + API_PATHS.workOrder.mediaContent(10, 12), + expect.objectContaining({ credentials: "include", throwHttpErrors: false }), + ); + }); + + it("throws ApiError on 404 without inventing a filename", async () => { + apiGetFn.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ fileName: "secret.pdf" }), + }); + + await expect(workOrdersApi.getMediaContent(10, 12)).rejects.toMatchObject({ + name: "ApiError", + status: 404, + message: "The requested resource was not found.", + }); + }); +}); From 47f78fec89c7fd76943f67fee3174d2d2e4597fc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:33:21 -0300 Subject: [PATCH 06/10] feat(work-orders): open Extra Docs through authorized content --- .../detail/tabs/slide-over-extras-tab.tsx | 34 ++++++++++++++---- .../detail/work-order-slide-over-tabs.tsx | 1 + .../slide-over-extras-aveta.test.tsx | 36 ++++++++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index c8728b70..d7bb285c 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; import { @@ -43,21 +44,36 @@ type SlideOverExtrasTabProps = { isReadOnly: boolean; pending?: boolean; avetaRequired?: boolean; + workOrderId?: string | number; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +let lastExtraDocObjectUrl: string | null = null; + +async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { + const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); + if (lastExtraDocObjectUrl) { + URL.revokeObjectURL(lastExtraDocObjectUrl); + } + const objectUrl = URL.createObjectURL(blob); + lastExtraDocObjectUrl = objectUrl; + window.open(objectUrl, "_blank", "noopener,noreferrer"); +} + function ExtraDocRow({ file, isReadOnly, pending, + workOrderId, onRemove, onCategorize, }: { file: MediaFile; isReadOnly: boolean; pending: boolean; + workOrderId?: string | number; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }) { @@ -75,15 +91,19 @@ function ExtraDocRow({ className="shrink-0" style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }} /> - { + if (workOrderId == null) return; + void openExtraDocContent(workOrderId, file.id).catch(() => { + toast.error("Unable to open this document."); + }); + }} + className="min-w-0 flex-1 truncate text-left hover:underline" style={{ fontSize: 12, color: "var(--foreground)" }} > {file.name} - + {showCategory ? ( - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after - upload + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file { - it("keeps Photos accept copy media-only at 50 MB", () => { + it("matches the AAP Photos accept copy and keeps it media-only", () => { renderWithProviders( ()} @@ -15,7 +15,10 @@ describe("MediaUploaderDropzone", () => { />, ); - expect(screen.getByText(/Accepted: JPG, PNG, MP4, MOV · Max 50 MB/)).toBeInTheDocument(); + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + ).toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); expect(screen.queryByText(/PDF/)).not.toBeInTheDocument(); expect(screen.queryByText(/DOC/)).not.toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index 05f0edd8..d3a6d09c 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -112,11 +112,19 @@ describe("SlideOverExtrasTab Aveta", () => { ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50 MB"), + screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"), ).toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toContain(".pdf"); expect(input?.getAttribute("accept")).toContain(".doc"); expect(input?.getAttribute("accept")).toContain(".docx"); }); + + it("disables document opening until the work order id is available", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); + }); }); From e761e0dae587ab200a1e40fc145e317409d836ae Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 13:53:17 -0300 Subject: [PATCH 08/10] test(work-orders): drop Photos categorize-after-upload assertion --- .../(protected)/workorders/media-upload-modal-api.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index fd628353..1c43219a 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -116,7 +116,10 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); - expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); + expect( + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + ).toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); }); From ba69706fcfe92c21ec0d85bd190ed3fdb77e04da Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 17:09:26 -0300 Subject: [PATCH 09/10] fix(work-orders): make extra document opening reliable --- .../detail/tabs/slide-over-extras-tab.tsx | 27 +++++++------ .../slide-over-extras-aveta.test.tsx | 38 ++++++++++++++++++- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index cf231f2a..eee8b3ec 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -50,16 +50,23 @@ type SlideOverExtrasTabProps = { onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; -let lastExtraDocObjectUrl: string | null = null; - async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { - const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); - if (lastExtraDocObjectUrl) { - URL.revokeObjectURL(lastExtraDocObjectUrl); + const tab = window.open("about:blank", "_blank"); + if (!tab) { + toast.error("Unable to open this document. Please allow pop-ups and try again."); + return; + } + + try { + const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); + const objectUrl = URL.createObjectURL(blob); + tab.opener = null; + tab.location.href = objectUrl; + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } catch { + tab.close(); + toast.error("Unable to open this document."); } - const objectUrl = URL.createObjectURL(blob); - lastExtraDocObjectUrl = objectUrl; - window.open(objectUrl, "_blank", "noopener,noreferrer"); } function ExtraDocRow({ @@ -98,9 +105,7 @@ function ExtraDocRow({ if (workOrderId == null) { return; } - void openExtraDocContent(workOrderId, file.id).catch(() => { - toast.error("Unable to open this document."); - }); + void openExtraDocContent(workOrderId, file.id); }} className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50" style={{ fontSize: 12, color: "var(--foreground)" }} diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index d3a6d09c..f788e250 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; @@ -82,7 +83,12 @@ describe("SlideOverExtrasTab Aveta", () => { const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent").mockResolvedValue(blob); const createObjectURL = vi.fn(() => "blob:authorized"); vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn() }); - const open = vi.fn(); + const tab = { + opener: window, + location: { href: "about:blank" }, + close: vi.fn(), + }; + const open = vi.fn(() => tab); vi.stubGlobal("open", open); renderWithProviders( @@ -103,7 +109,35 @@ describe("SlideOverExtrasTab Aveta", () => { expect(getMediaContent).toHaveBeenCalledWith(10, "m1"); }); expect(createObjectURL).toHaveBeenCalledWith(blob); - expect(open).toHaveBeenCalledWith("blob:authorized", "_blank", "noopener,noreferrer"); + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(tab.opener).toBeNull(); + expect(tab.location.href).toBe("blob:authorized"); + }); + + it("shows an error when the browser blocks the document tab", async () => { + const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent"); + const toastError = vi.spyOn(toast, "error").mockImplementation(vi.fn()); + vi.stubGlobal( + "open", + vi.fn(() => null), + ); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" })); + + expect(toastError).toHaveBeenCalledWith( + "Unable to open this document. Please allow pop-ups and try again.", + ); + expect(getMediaContent).not.toHaveBeenCalled(); }); it("shows Extra Docs accept copy with PDF DOC and 50MB", () => { From 117f7949f52441c4a7b4703772a36cb3b63e681c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 18:05:03 -0300 Subject: [PATCH 10/10] fix(work-orders): pass HTTP status into Extra Docs error helper --- src/domain/work-orders/api/work-order-board-documents-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 6d299940..ec0d9148 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -212,7 +212,7 @@ export const workOrderBoardDocumentsApi = { } const data = await readResponseJson(response); - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, uploadCompletionDoc: async (