mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
fix(media): count HEIC and untyped phone files, guard overlapping picks
Persisted HEIC files and local files with an empty or octet-stream type were classified as "other", so they did not count toward the 10 photo / 3 video limit. Both classifiers now recognise them by extension. The Photos & Videos uploader and Extra Docs ignore a new selection while the previous one is still being screened, and the vendor portal drops an earlier pick whose video check finishes after a newer pick. Also removes ticket keys from source comments.
This commit is contained in:
parent
b316168930
commit
6012ebabe7
13 changed files with 171 additions and 27 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
File as FileIcon,
|
File as FileIcon,
|
||||||
FileText,
|
FileText,
|
||||||
|
|
@ -18,7 +18,10 @@ import {
|
||||||
hasExplicitAvetaFile,
|
hasExplicitAvetaFile,
|
||||||
resolveExtraDocCategory,
|
resolveExtraDocCategory,
|
||||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
import {
|
||||||
|
screenWorkOrderUploads,
|
||||||
|
type WorkOrderUploadScreenResult,
|
||||||
|
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
countWorkOrderMediaKinds,
|
countWorkOrderMediaKinds,
|
||||||
|
|
@ -189,16 +192,30 @@ export function SlideOverExtrasTab({
|
||||||
}: SlideOverExtrasTabProps) {
|
}: SlideOverExtrasTabProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||||
|
// One selection at a time: a second pick during the async screen would read the
|
||||||
|
// same photo/video counts and could pass the per-work-order limit.
|
||||||
|
const screeningRef = useRef(false);
|
||||||
|
const [screening, setScreening] = useState(false);
|
||||||
|
const busy = pending || screening;
|
||||||
|
|
||||||
const addFiles = async (files: FileList | null) => {
|
const addFiles = async (files: FileList | null) => {
|
||||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||||
// Snapshot synchronously: the input is reset right after this handler returns.
|
// Snapshot synchronously: the input is reset right after this handler returns.
|
||||||
const selected = Array.from(files);
|
const selected = Array.from(files);
|
||||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
screeningRef.current = true;
|
||||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
setScreening(true);
|
||||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
let screened: WorkOrderUploadScreenResult;
|
||||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
try {
|
||||||
});
|
screened = await screenWorkOrderUploads(selected, {
|
||||||
|
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||||
|
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||||
|
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
screeningRef.current = false;
|
||||||
|
setScreening(false);
|
||||||
|
}
|
||||||
|
const { accepted, rejections } = screened;
|
||||||
for (const message of rejections) toast.error(message);
|
for (const message of rejections) toast.error(message);
|
||||||
if (accepted.length > 0) {
|
if (accepted.length > 0) {
|
||||||
onUpload(accepted);
|
onUpload(accepted);
|
||||||
|
|
@ -231,7 +248,7 @@ export function SlideOverExtrasTab({
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={busy}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
|
|
@ -248,9 +265,9 @@ export function SlideOverExtrasTab({
|
||||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||||
aria-busy={pending || undefined}
|
aria-busy={busy || undefined}
|
||||||
>
|
>
|
||||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
{busy ? "Uploading…" : "Upload documents, photos or videos"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
||||||
|
|
@ -263,7 +280,7 @@ export function SlideOverExtrasTab({
|
||||||
accept={workOrderExtraDocsAcceptAttribute()}
|
accept={workOrderExtraDocsAcceptAttribute()}
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
disabled={pending}
|
disabled={busy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void addFiles(event.target.files);
|
void addFiles(event.target.files);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
@ -9,7 +9,10 @@ import {
|
||||||
type WorkOrderMediaKindCounts,
|
type WorkOrderMediaKindCounts,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
import {
|
||||||
|
screenWorkOrderUploads,
|
||||||
|
type WorkOrderUploadScreenResult,
|
||||||
|
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
|
|
||||||
|
|
@ -50,18 +53,31 @@ export function MediaUploader({
|
||||||
mediaCounts,
|
mediaCounts,
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// One selection at a time: a second pick during the async screen would read the
|
||||||
|
// same photo/video counts and could pass the per-work-order limit.
|
||||||
|
const screeningRef = useRef(false);
|
||||||
|
const [screening, setScreening] = useState(false);
|
||||||
|
|
||||||
const addFiles = async (files: FileList | null) => {
|
const addFiles = async (files: FileList | null) => {
|
||||||
if (readOnly || pending || !files || files.length === 0) return;
|
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||||
|
|
||||||
// Snapshot synchronously: the caller resets the input value right after this
|
// Snapshot synchronously: the caller resets the input value right after this
|
||||||
// handler returns, which can detach a live FileList across awaits.
|
// handler returns, which can detach a live FileList across awaits.
|
||||||
const selected = Array.from(files);
|
const selected = Array.from(files);
|
||||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
screeningRef.current = true;
|
||||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
setScreening(true);
|
||||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
let screened: WorkOrderUploadScreenResult;
|
||||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
try {
|
||||||
});
|
screened = await screenWorkOrderUploads(selected, {
|
||||||
|
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||||
|
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||||
|
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
screeningRef.current = false;
|
||||||
|
setScreening(false);
|
||||||
|
}
|
||||||
|
const { accepted, rejections } = screened;
|
||||||
for (const message of rejections) toast.error(message);
|
for (const message of rejections) toast.error(message);
|
||||||
if (accepted.length === 0) return;
|
if (accepted.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -96,7 +112,7 @@ export function MediaUploader({
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<MediaUploaderDropzone
|
<MediaUploaderDropzone
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
pending={pending}
|
pending={pending || screening}
|
||||||
categorizeAfterUpload={Boolean(onCategorize)}
|
categorizeAfterUpload={Boolean(onCategorize)}
|
||||||
onFilesSelected={(files) => void addFiles(files)}
|
onFilesSelected={(files) => void addFiles(files)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export function VendorPortalDocuments({
|
||||||
const [replacementId, setReplacementId] = useState<number>();
|
const [replacementId, setReplacementId] = useState<number>();
|
||||||
const [progress, setProgress] = useState<number>();
|
const [progress, setProgress] = useState<number>();
|
||||||
const [validationError, setValidationError] = useState<string>();
|
const [validationError, setValidationError] = useState<string>();
|
||||||
|
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
|
||||||
|
const selectionRef = useRef(0);
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||||
|
|
@ -59,6 +61,7 @@ export function VendorPortalDocuments({
|
||||||
});
|
});
|
||||||
|
|
||||||
const chooseFile = (documentId?: number) => {
|
const chooseFile = (documentId?: number) => {
|
||||||
|
selectionRef.current += 1;
|
||||||
setValidationError(undefined);
|
setValidationError(undefined);
|
||||||
uploadMutation.reset();
|
uploadMutation.reset();
|
||||||
setReplacementId(documentId);
|
setReplacementId(documentId);
|
||||||
|
|
@ -70,11 +73,15 @@ export function VendorPortalDocuments({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selection = ++selectionRef.current;
|
||||||
const replaced = documents.find((document) => document.id === replacementId);
|
const replaced = documents.find((document) => document.id === replacementId);
|
||||||
const validationMessage = await validateVendorDocument(
|
const validationMessage = await validateVendorDocument(
|
||||||
file,
|
file,
|
||||||
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
|
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
|
||||||
);
|
);
|
||||||
|
if (selection !== selectionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (validationMessage) {
|
if (validationMessage) {
|
||||||
setValidationError(validationMessage);
|
setValidationError(validationMessage);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendo
|
||||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SH-116 client-confirmed media contract on the vendor portal: photos up to
|
* Client-confirmed media contract on the vendor portal: photos up to
|
||||||
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
|
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
|
||||||
* PDF documents keep the previous 10 MB document cap.
|
* PDF documents keep the previous 10 MB document cap.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
||||||
upliftRequests: VendorPortalUpliftRequest[];
|
upliftRequests: VendorPortalUpliftRequest[];
|
||||||
signoffs: VendorPortalSignoff[];
|
signoffs: VendorPortalSignoff[];
|
||||||
documents: VendorPortalDocument[];
|
documents: VendorPortalDocument[];
|
||||||
/** SH-116 per-work-order photo/video usage; absent until the backend reports it. */
|
/** Per-work-order photo/video usage; absent until the backend reports it. */
|
||||||
mediaCounts?: VendorPortalMediaCounts | null;
|
mediaCounts?: VendorPortalMediaCounts | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ function cryptoRandomId(): string {
|
||||||
|
|
||||||
function classifyFileName(fileName: string, url: string): FileCategory {
|
function classifyFileName(fileName: string, url: string): FileCategory {
|
||||||
const name = `${fileName} ${url}`.toLowerCase();
|
const name = `${fileName} ${url}`.toLowerCase();
|
||||||
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export type WorkOrderUploadScreen = {
|
||||||
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
|
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SH-116 browser pre-check shared by every dispatcher upload surface: type, size,
|
* Browser pre-check shared by every dispatcher upload surface: type, size,
|
||||||
* per-work-order photo/video count (including earlier files in the same selection)
|
* per-work-order photo/video count (including earlier files in the same selection)
|
||||||
* and video duration. The server enforces the same contract; a video whose duration
|
* and video duration. The server enforces the same contract; a video whose duration
|
||||||
* the browser cannot read is never blocked here.
|
* the browser cannot read is never blocked here.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB
|
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
|
||||||
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
|
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
|
||||||
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
|
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
|
||||||
* the Completion Doc → Media tab and the vendor portal upload. Duration is
|
* the Completion Doc → Media tab and the vendor portal upload. Duration is
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
MediaCategory,
|
MediaCategory,
|
||||||
MediaFile,
|
MediaFile,
|
||||||
} from "@/domain/work-orders/types/work-order-media";
|
} from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
let mediaIdCounter = 0;
|
let mediaIdCounter = 0;
|
||||||
|
|
||||||
|
|
@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
|
||||||
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
|
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
|
||||||
return { fileType: "pdf", category: "Report" };
|
return { fileType: "pdf", category: "Report" };
|
||||||
}
|
}
|
||||||
|
// Phones often send an empty or octet-stream type; fall back to the extension.
|
||||||
|
const kind = resolveWorkOrderUploadKind(f);
|
||||||
|
if (kind === "photo") return { fileType: "image", category: "Photo" };
|
||||||
|
if (kind === "video") return { fileType: "video", category: "Video" };
|
||||||
return { fileType: "other", category: "Other" };
|
return { fileType: "other", category: "Other" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
|
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
|
||||||
* phone file with `moov` after a large `mdat` costs only a few small reads.
|
* phone file with `moov` after a large `mdat` costs only a few small reads.
|
||||||
* Resolves null when the header cannot be read — callers must not block the
|
* Resolves null when the header cannot be read — callers must not block the
|
||||||
* upload in that case (SH-116: the server enforces the limit from the same
|
* upload in that case (the server enforces the limit from the same
|
||||||
* header and also lets unreadable metadata through).
|
* header and also lets unreadable metadata through).
|
||||||
*/
|
*/
|
||||||
const MAX_BOXES_PER_LEVEL = 1024;
|
const MAX_BOXES_PER_LEVEL = 1024;
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,36 @@ describe("SlideOverExtrasTab SH-116 media contract", () => {
|
||||||
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
|
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores a second pick while the first one is still being checked", async () => {
|
||||||
|
let finishProbe: ((seconds: number) => void) | undefined;
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishProbe = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[]}
|
||||||
|
mediaCounts={{ photos: 0, videos: 2 }}
|
||||||
|
isReadOnly={false}
|
||||||
|
onUpload={onUpload}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
|
||||||
|
const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
|
||||||
|
|
||||||
|
selectFiles([first]);
|
||||||
|
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||||
|
expect(document.querySelector('input[type="file"]')).toBeDisabled();
|
||||||
|
selectFiles([second]);
|
||||||
|
finishProbe?.(30);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
|
||||||
|
expect(onUpload).toHaveBeenCalledWith([first]);
|
||||||
|
expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -247,4 +247,40 @@ describe("VendorPortalDocuments", () => {
|
||||||
finishUpload?.();
|
finishUpload?.();
|
||||||
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
|
||||||
|
let finishProbe: ((seconds: number) => void) | undefined;
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishProbe = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const upload = vi
|
||||||
|
.spyOn(vendorPortalApi, "uploadDocument")
|
||||||
|
.mockResolvedValue(quarantinedDocument);
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={7}
|
||||||
|
documents={[]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Choose completion document");
|
||||||
|
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
|
||||||
|
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { files: [video] } });
|
||||||
|
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||||
|
fireEvent.change(input, { target: { files: [photo] } });
|
||||||
|
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||||
|
finishProbe?.(30);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(upload).toHaveBeenCalledOnce();
|
||||||
|
expect(upload.mock.calls[0][0].file).toBe(photo);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
boardMediaItemsToMediaFiles,
|
||||||
|
mapBoardDetailMediaItem,
|
||||||
|
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||||
|
|
||||||
|
describe("photo/video counting for the per-work-order limit", () => {
|
||||||
|
it("counts a persisted HEIC photo after a refresh", () => {
|
||||||
|
const heic = mapBoardDetailMediaItem({
|
||||||
|
id: 41,
|
||||||
|
category: 3,
|
||||||
|
url: "https://cdn.example.com/IMG_0042.HEIC",
|
||||||
|
fileName: "IMG_0042.HEIC",
|
||||||
|
});
|
||||||
|
const files = boardMediaItemsToMediaFiles([heic]);
|
||||||
|
|
||||||
|
expect(files[0]?.fileType).toBe("image");
|
||||||
|
expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ name: "IMG_0043.HEIC", type: "" }, "image"],
|
||||||
|
[{ name: "IMG_0044.MOV", type: "" }, "video"],
|
||||||
|
[{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
|
||||||
|
[{ name: "report.docx", type: "" }, "other"],
|
||||||
|
])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
|
||||||
|
expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue