mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: preserve retry and batch media limits
This commit is contained in:
parent
f822c2cc9b
commit
340dd5252b
13 changed files with 207 additions and 9 deletions
|
|
@ -33,6 +33,14 @@ export function isNetworkUploadError(error: unknown): boolean {
|
|||
return error instanceof MediaUploadNetworkError;
|
||||
}
|
||||
|
||||
/** Retryable HTTP failures keep the same client idempotency key/session. */
|
||||
export function isRetryableMediaUploadError(error: unknown): boolean {
|
||||
if (isNetworkUploadError(error)) return true;
|
||||
if (!(error instanceof Error) || error.name !== "MediaUploadHttpError") return false;
|
||||
const status = (error as Error & { status?: unknown }).status;
|
||||
return typeof status === "number" && status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type SlideOverCompletionMediaProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export function SlideOverCompletionMedia({
|
|||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: SlideOverCompletionMediaProps) {
|
||||
return (
|
||||
|
|
@ -64,6 +66,7 @@ export function SlideOverCompletionMedia({
|
|||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type SlideOverCompletionTabProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
onUploadCompletionPdf?: (file: File) => void;
|
||||
onOpenCompletionDoc: () => void;
|
||||
|
|
@ -59,6 +60,7 @@ export function SlideOverCompletionTab({
|
|||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
onUploadCompletionPdf,
|
||||
onOpenCompletionDoc,
|
||||
|
|
@ -85,6 +87,7 @@ export function SlideOverCompletionTab({
|
|||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
|
|
@ -254,7 +255,7 @@ function hasEmergencyMediaType(...types: Array<string | undefined>): boolean {
|
|||
|
||||
function getEmergencyUploaderOptions(
|
||||
emergencyMedia: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!emergencyMedia) {
|
||||
return {
|
||||
|
|
@ -262,6 +263,7 @@ function getEmergencyUploaderOptions(
|
|||
emergencyMediaTypeErrorMessage: undefined,
|
||||
emergencyMediaAcceptAttribute: undefined,
|
||||
emergencyMediaSizeError: undefined,
|
||||
emergencyMediaBatchFileError: undefined,
|
||||
emergencyMediaUploadHint: undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -270,7 +272,9 @@ function getEmergencyUploaderOptions(
|
|||
emergencyMediaTypeErrorMessage:
|
||||
"Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
emergencyMediaBatchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
};
|
||||
}
|
||||
|
|
@ -376,7 +380,7 @@ export function useSlideOverMediaState({
|
|||
deleteMediaMutation.isPending,
|
||||
categorizeMediaMutation.isPending,
|
||||
);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy);
|
||||
|
||||
return {
|
||||
completionMedia,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export function WorkOrderSlideOverTabs({
|
|||
typeErrorMessage={state.emergencyMediaTypeErrorMessage}
|
||||
acceptAttribute={state.emergencyMediaAcceptAttribute}
|
||||
fileSizeError={state.emergencyMediaSizeError}
|
||||
batchFileError={state.emergencyMediaBatchFileError}
|
||||
uploadHint={state.emergencyMediaUploadHint}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
|
|
@ -60,11 +61,13 @@ function emergencyPolicyMessage(
|
|||
|
||||
function getEmergencyUploaderOptions(
|
||||
isEmergency: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!isEmergency) return {};
|
||||
return {
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
batchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
typeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
|
|
@ -95,7 +98,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy?.limits);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy);
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type MediaUploaderProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ export function MediaUploader({
|
|||
typeErrorMessage = "Unsupported file type.",
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -74,6 +76,11 @@ export function MediaUploader({
|
|||
toast.error(sizeError);
|
||||
continue;
|
||||
}
|
||||
const batchError = batchFileError?.(file, accepted);
|
||||
if (batchError) {
|
||||
toast.error(batchError);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
VendorMediaItem,
|
||||
VendorPortalMedia,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { MediaUploadNetworkError } from "@/api/media-upload-client";
|
||||
import { isRetryableMediaUploadError } from "@/api/media-upload-client";
|
||||
import { uploadFileResumable } from "@/api/media-upload-engine";
|
||||
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import {
|
||||
|
|
@ -112,7 +112,7 @@ function VendorUploadError({
|
|||
return (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
|
||||
{!locked && error instanceof MediaUploadNetworkError && (
|
||||
{!locked && isRetryableMediaUploadError(error) && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
|
||||
Retry upload
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,19 @@ export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUpload
|
|||
return `${kind} limit reached (${max} per work order). Remove a file to add another.`;
|
||||
}
|
||||
|
||||
export function emergencyMediaBatchCountError(
|
||||
file: File,
|
||||
acceptedFiles: readonly File[],
|
||||
policy?: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||
): string | undefined {
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind || !policy) return undefined;
|
||||
const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos;
|
||||
const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind);
|
||||
if (alreadyAccepted.length < available) return undefined;
|
||||
return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits));
|
||||
}
|
||||
|
||||
export function emergencyMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,4 +288,35 @@ describe("MediaUploadModal API wiring", () => {
|
|||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects excess Emergency files in a multi-select batch using remaining policy slots", async () => {
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
resumableUploadMutateAsync.mockResolvedValue({ id: "uploaded" });
|
||||
renderModal();
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["clip"], "clip.mov", { type: "video/quicktime" });
|
||||
fireEvent.change(input, { target: { files: [photoOne, photoTwo, video] } });
|
||||
|
||||
await waitFor(() => expect(resumableUploadMutateAsync).toHaveBeenCalledTimes(2));
|
||||
expect(resumableUploadMutateAsync.mock.calls.map(([request]) => request.file)).toEqual([
|
||||
photoOne,
|
||||
video,
|
||||
]);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { SlideOverCompletionMedia } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media";
|
||||
|
||||
const policyState = vi.hoisted(() => ({
|
||||
data: undefined as unknown,
|
||||
|
|
@ -460,6 +461,80 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
).toBe('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||
});
|
||||
|
||||
it("exposes per-batch remaining-slot validation to the slide-over uploader", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const photo = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["video"], "video.mov", { type: "video/quicktime" });
|
||||
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(video, [photo])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [photo])).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps multi-select within Reactive remaining slots on the visible slide-over surface", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 3 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const onUploadMedia = vi.fn();
|
||||
render(
|
||||
<SlideOverCompletionMedia
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
mediaPending={false}
|
||||
onUploadMedia={onUploadMedia}
|
||||
batchFileError={result.current.emergencyMediaBatchFileError}
|
||||
/>,
|
||||
);
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [photoOne, photoTwo] },
|
||||
});
|
||||
|
||||
expect(onUploadMedia).toHaveBeenCalledTimes(1);
|
||||
expect(onUploadMedia.mock.calls[0]?.[0].map((request) => request.file)).toEqual([photoOne]);
|
||||
});
|
||||
|
||||
it("reuses the same idempotency key when a failed media tile is retried", async () => {
|
||||
const mutateAsync = vi
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,42 @@ describe("Vendor media controls", () => {
|
|||
expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey);
|
||||
});
|
||||
|
||||
it("offers same-session retry after a transient HTTP 503", async () => {
|
||||
const unavailable = Object.assign(new Error("Upload request failed (HTTP 503)."), {
|
||||
name: "MediaUploadHttpError",
|
||||
status: 503,
|
||||
});
|
||||
const upload = vi
|
||||
.spyOn(mediaUploadEngine, "uploadFileResumable")
|
||||
.mockRejectedValueOnce(unavailable)
|
||||
.mockResolvedValue({
|
||||
uploadId: "upload-503",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 2 },
|
||||
});
|
||||
renderVendorMedia();
|
||||
|
||||
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Retry upload" })).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry upload" }));
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
|
||||
expect(upload.mock.calls[1]?.[0].file).toBe(file);
|
||||
expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey);
|
||||
});
|
||||
|
||||
it("aborts the active vendor upload from the visible Cancel control", async () => {
|
||||
const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation(
|
||||
({ signal }) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
mediaKindLimitReachedNotice,
|
||||
|
|
@ -81,6 +82,19 @@ describe("Emergency/Reactive media limits", () => {
|
|||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("counts already accepted files in the current batch against remaining slots", () => {
|
||||
const policy = { limits: BASE_POLICY.limits, remaining: { photos: 1, videos: 1 } };
|
||||
const firstPhoto = fileOf(10, "first.jpg", "image/jpeg");
|
||||
const extraPhoto = fileOf(10, "extra.jpg", "image/jpeg");
|
||||
const firstVideo = fileOf(10, "first.mov", "video/quicktime");
|
||||
|
||||
expect(emergencyMediaBatchCountError(firstPhoto, [], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(firstVideo, [firstPhoto], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(extraPhoto, [firstPhoto], policy)).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a standard safe over-limit notice without raw payloads", async () => {
|
||||
const notice = mediaOverLimitNotice(
|
||||
fileOf(100_000_001, "big.mov", "video/quicktime"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue