merge: sync feature/wo-wizard-board-create onto refreshed slide-over parent

This commit is contained in:
arthur.bassi 2026-08-04 16:38:22 -03:00
commit 162d47fe1a
11 changed files with 209 additions and 129 deletions

View file

@ -11,13 +11,10 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site"; import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
DOC_STATE_CFG,
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions"; import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
type UseCompDocDialogArgs = { type UseCompDocDialogArgs = {
row: WorkOrderTableRow; row: WorkOrderTableRow;
@ -114,15 +111,21 @@ export function useCompDocDialog({
seaRep, seaRep,
}; };
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => { const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
await onSave({ notes, generatedAt, ...extra }, status); useCompDocPersistFlow({
}; workOrderId: row.id,
notes,
const syncDateIfChanged = async () => { generatedAt,
if (editDateCompleted !== (info.completedDate || "")) { editDateCompleted,
await onDateCompletedChange?.(editDateCompleted); currentCompletedDate: info.completedDate || "",
} onSave,
}; onDateCompletedChange,
onGenerated: (ts) => {
setGeneratedAt(ts);
setDocState("generated");
},
onDraftSaved: notifyDraftSaved,
});
function handleMarkNotRequired() { function handleMarkNotRequired() {
setDocState("not-required"); setDocState("not-required");
@ -134,28 +137,6 @@ export function useCompDocDialog({
void persist({}, "No"); void persist({}, "No");
} }
async function handleSaveDraft() {
try {
await syncDateIfChanged();
await persist({}, "No");
notifyDraftSaved();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
setGeneratedAt(ts);
setDocState("generated");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({ const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info, info,
signOffAttachment, signOffAttachment,
@ -168,6 +149,7 @@ export function useCompDocDialog({
syncDateIfChanged, syncDateIfChanged,
setGeneratedAt, setGeneratedAt,
markGenerated: () => setDocState("generated"), markGenerated: () => setDocState("generated"),
onPersistFailure: refreshAuthoritativeState,
}); });
return { return {

View file

@ -25,6 +25,7 @@ type UseCompDocPdfActionsArgs = {
syncDateIfChanged: () => Promise<void>; syncDateIfChanged: () => Promise<void>;
setGeneratedAt: (value: string) => void; setGeneratedAt: (value: string) => void;
markGenerated: () => void; markGenerated: () => void;
onPersistFailure?: () => void;
}; };
export function useCompDocPdfActions({ export function useCompDocPdfActions({
@ -39,6 +40,7 @@ export function useCompDocPdfActions({
syncDateIfChanged, syncDateIfChanged,
setGeneratedAt, setGeneratedAt,
markGenerated, markGenerated,
onPersistFailure,
}: UseCompDocPdfActionsArgs) { }: UseCompDocPdfActionsArgs) {
function handleDownloadPdf() { function handleDownloadPdf() {
if (signOffAttachment) { if (signOffAttachment) {
@ -75,6 +77,7 @@ export function useCompDocPdfActions({
setGeneratedAt(ts); setGeneratedAt(ts);
markGenerated(); markGenerated();
} catch (error) { } catch (error) {
onPersistFailure?.();
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to finalize completion document", error instanceof Error ? error.message : "Failed to finalize completion document",
); );

View file

@ -0,0 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import { queryKeys } from "@/infra/query-key/query-key";
type UseCompDocPersistFlowArgs = {
workOrderId: string | number;
notes: string;
generatedAt: string;
editDateCompleted: string;
currentCompletedDate: string;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
onGenerated: (generatedAt: string) => void;
onDraftSaved: () => void;
};
export function useCompDocPersistFlow({
workOrderId,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate,
onSave,
onDateCompletedChange,
onGenerated,
onDraftSaved,
}: UseCompDocPersistFlowArgs) {
const queryClient = useQueryClient();
const refreshAuthoritativeState = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(workOrderId),
});
};
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
await onSave({ notes, generatedAt, ...extra }, status);
};
const syncDateIfChanged = async () => {
if (editDateCompleted !== currentCompletedDate) {
await onDateCompletedChange?.(editDateCompleted);
}
};
async function handleSaveDraft() {
try {
// Prerequisites first; docStatus last.
await syncDateIfChanged();
await persist({}, "No");
onDraftSaved();
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
// Prerequisites first; only mark Generated after completedDate persists.
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
onGenerated(ts);
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
return {
persist,
syncDateIfChanged,
refreshAuthoritativeState,
handleSaveDraft,
handleGenerate,
};
}

View file

@ -56,7 +56,6 @@ export function useSlideOverMediaState({
for (const request of requests) { for (const request of requests) {
await uploadMediaMutation.mutateAsync({ await uploadMediaMutation.mutateAsync({
file: request.file, file: request.file,
signal: request.signal,
onProgress: request.onProgress, onProgress: request.onProgress,
}); });
} }

View file

@ -29,10 +29,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id); const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id); const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession( const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
detail, useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
(input) => uploadMutation.mutateAsync(input),
);
const pending = const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending; uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
@ -85,7 +83,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
}} }}
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })} onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
onRetry={retryUpload} onRetry={retryUpload}
onCancel={removeLocal} onCancel={cancelUpload}
readOnly={isReadOnly} readOnly={isReadOnly}
pending={pending} pending={pending}
/> />

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useRef } 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";
@ -15,7 +15,6 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
export type MediaUploadRequest = { export type MediaUploadRequest = {
localId: string; localId: string;
file: File; file: File;
signal: AbortSignal;
onProgress: (percent: number) => void; onProgress: (percent: number) => void;
}; };
@ -25,7 +24,7 @@ type MediaUploaderProps = {
onUpload?: (requests: MediaUploadRequest[]) => void; onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (localId: string, signal: AbortSignal) => void; onRetry?: (localId: string) => void;
onCancel?: (localId: string) => void; onCancel?: (localId: string) => void;
/** Legacy local-only mode (table modal fallback). */ /** Legacy local-only mode (table modal fallback). */
onChange?: (next: MediaFile[]) => void; onChange?: (next: MediaFile[]) => void;
@ -45,7 +44,6 @@ export function MediaUploader({
pending = false, pending = false,
}: MediaUploaderProps) { }: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
const addFiles = (files: FileList | null) => { const addFiles = (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return; if (readOnly || pending || !files || files.length === 0) return;
@ -65,19 +63,11 @@ export function MediaUploader({
if (accepted.length === 0) return; if (accepted.length === 0) return;
if (onUpload) { if (onUpload) {
const nextControllers: Record<string, AbortController> = { ...controllers }; const requests: MediaUploadRequest[] = accepted.map((file) => ({
const requests: MediaUploadRequest[] = accepted.map((file) => { localId: `local-${crypto.randomUUID()}`,
const localId = `local-${crypto.randomUUID()}`; file,
const controller = new AbortController(); onProgress: () => undefined,
nextControllers[localId] = controller; }));
return {
localId,
file,
signal: controller.signal,
onProgress: () => undefined,
};
});
setControllers(nextControllers);
onUpload(requests); onUpload(requests);
return; return;
} }
@ -98,18 +88,6 @@ export function MediaUploader({
onChange?.(uploads.filter((u) => u.id !== id)); onChange?.(uploads.filter((u) => u.id !== id));
}; };
const cancel = (id: string) => {
controllers[id]?.abort();
onCancel?.(id);
};
const retry = (id: string) => {
controllers[id]?.abort();
const controller = new AbortController();
setControllers((prev) => ({ ...prev, [id]: controller }));
onRetry?.(id, controller.signal);
};
return ( return (
<div> <div>
{!readOnly && ( {!readOnly && (
@ -121,8 +99,8 @@ export function MediaUploader({
pending={pending} pending={pending}
onRemove={remove} onRemove={remove}
onCategorize={onCategorize} onCategorize={onCategorize}
onRetry={onRetry ? retry : undefined} onRetry={onRetry}
onCancel={cancel} onCancel={onCancel}
/> />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } 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";
@ -13,7 +13,13 @@ type UploadMutate = (input: {
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
}) => Promise<unknown>; }) => Promise<unknown>;
function toLocalUpload(request: MediaUploadRequest): LocalUpload { type SessionUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
};
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file); const classified = classifyUpload(request.file);
return { return {
id: request.localId, id: request.localId,
@ -29,7 +35,7 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
}; };
} }
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) { function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal; return item.id === request.localId && item.signal === request.signal;
} }
@ -38,6 +44,7 @@ export function useMediaUploadSession(
uploadMutateAsync: UploadMutate, uploadMutateAsync: UploadMutate,
) { ) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]); const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
const serverUploads = useMemo( const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []), () => boardMediaItemsToMediaFiles(detail?.media ?? []),
@ -52,10 +59,27 @@ export function useMediaUploadSession(
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))]; return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
}, [serverUploads, localUploads]); }, [serverUploads, localUploads]);
function clearController(localId: string) {
controllersRef.current.delete(localId);
}
function replaceController(localId: string): AbortController {
controllersRef.current.get(localId)?.abort();
const controller = new AbortController();
controllersRef.current.set(localId, controller);
return controller;
}
function startUploads(requests: MediaUploadRequest[]) { function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId,
file: request.file,
signal: replaceController(request.localId).signal,
}));
setLocalUploads((prev) => { setLocalUploads((prev) => {
const next = [...prev]; const next = [...prev];
for (const request of requests) { for (const request of sessionRequests) {
const entry = toLocalUpload(request); const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId); const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) { if (index >= 0) {
@ -71,7 +95,7 @@ export function useMediaUploadSession(
return next; return next;
}); });
for (const request of requests) { for (const request of sessionRequests) {
void uploadMutateAsync({ void uploadMutateAsync({
file: request.file, file: request.file,
signal: request.signal, signal: request.signal,
@ -84,13 +108,16 @@ export function useMediaUploadSession(
}, },
}) })
.then(() => { .then(() => {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request))); setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
}) })
.catch((error: Error) => { .catch((error: Error) => {
if (error.name === "AbortError") { if (error.name === "AbortError") {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request))); setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return; return;
} }
// Keep controller entry only while retryable; replaceController on retry.
setLocalUploads((prev) => setLocalUploads((prev) =>
prev.map((item) => prev.map((item) =>
isSameAttempt(item, request) isSameAttempt(item, request)
@ -107,20 +134,32 @@ export function useMediaUploadSession(
} }
} }
function retryUpload(localId: string, signal: AbortSignal) { function retryUpload(localId: string) {
const target = localUploads.find((item) => item.id === localId); const target = localUploads.find((item) => item.id === localId);
if (!target?.sourceFile) return; if (!target?.sourceFile) return;
startUploads([ startUploads([
{ {
localId, localId,
file: target.sourceFile, file: target.sourceFile,
signal,
onProgress: () => undefined, onProgress: () => undefined,
}, },
]); ]);
} }
function cancelUpload(localId: string) {
controllersRef.current.get(localId)?.abort();
clearController(localId);
setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId);
if (target?.url.startsWith("blob:")) {
URL.revokeObjectURL(target.url);
}
return prev.filter((item) => item.id !== localId);
});
}
function removeLocal(id: string) { function removeLocal(id: string) {
clearController(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id)); setLocalUploads((prev) => prev.filter((item) => item.id !== id));
} }
@ -128,5 +167,5 @@ export function useMediaUploadSession(
return localUploads.some((item) => item.id === id); return localUploads.some((item) => item.id === id);
} }
return { uploads, startUploads, retryUpload, removeLocal, isLocal }; return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
} }

View file

@ -397,17 +397,6 @@ export function mapWorkOrderFormToUpdateFormData(
return formData; return formData;
} }
/** Partial EditWorkorder payload — only Id + CompletedDate (avoids legacy GetWorkorderById). */
export function mapCompletedDateUpdateFormData(
id: string | number,
completedDate: string,
): FormData {
const formData = new FormData();
formData.append("Id", String(id));
appendIfPresent(formData, "CompletedDate", completedDate);
return formData;
}
export function mapLocationOption(raw: unknown): LocationOption { export function mapLocationOption(raw: unknown): LocationOption {
const item = asRecord(raw); const item = asRecord(raw);
const name = readString(item, "name", "Name", "locationName", "LocationName"); const name = readString(item, "name", "Name", "locationName", "LocationName");

View file

@ -170,4 +170,28 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
}); });
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
const callOrder: string[] = [];
onDateCompletedChange.mockImplementation(async () => {
callOrder.push("date");
throw new Error("completedDate conflict");
});
onSave.mockImplementation(async () => {
callOrder.push("status");
});
const row = baseRow({ completedDate: "2026-07-01" });
renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() => {
expect(onDateCompletedChange).toHaveBeenCalled();
});
expect(callOrder).toEqual(["date"]);
expect(onSave).not.toHaveBeenCalled();
});
}); });

View file

@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
.mockImplementation(() => new Promise(() => undefined)); .mockImplementation(() => new Promise(() => undefined));
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync)); const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
const firstController = new AbortController();
const localId = "local-1"; const localId = "local-1";
const file = makeFile(); const file = makeFile();
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{ {
localId, localId,
file, file,
signal: firstController.signal,
onProgress: () => undefined, onProgress: () => undefined,
}, },
]); ]);
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads).toHaveLength(1); expect(result.current.uploads).toHaveLength(1);
}); });
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
act(() => { act(() => {
rejectFirst(new Error("network")); rejectFirst(new Error("network"));
}); });
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed"); expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
}); });
const retryController = new AbortController();
act(() => { act(() => {
result.current.retryUpload(localId, retryController.signal); result.current.retryUpload(localId);
}); });
await waitFor(() => { await waitFor(() => {
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading"); expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
}); });
expect(uploadMutateAsync).toHaveBeenCalledTimes(2); expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal); const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
expect(retrySignal).toBeInstanceOf(AbortSignal);
expect(retrySignal).not.toBe(firstSignal);
expect(retrySignal.aborted).toBe(false);
}); });
it("cancel after retry aborts the replacement upload and removes the local row", async () => { it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
let rejectFirst!: (error: Error) => void; let rejectFirst!: (error: Error) => void;
let resolveRetry!: (value: unknown) => void;
const uploadMutateAsync = vi const uploadMutateAsync = vi
.fn() .fn()
.mockImplementationOnce( .mockImplementationOnce(
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
) )
.mockImplementation( .mockImplementation(
({ signal }: { signal?: AbortSignal }) => ({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => { new Promise((resolve, reject) => {
resolveRetry = resolve;
signal?.addEventListener("abort", () => { signal?.addEventListener("abort", () => {
const error = new Error("aborted"); const error = new Error("aborted");
error.name = "AbortError"; error.name = "AbortError";
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{ {
localId, localId,
file, file,
signal: new AbortController().signal,
onProgress: () => undefined, onProgress: () => undefined,
}, },
]); ]);
@ -105,25 +108,28 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed"); expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
}); });
const retryController = new AbortController();
act(() => { act(() => {
result.current.retryUpload(localId, retryController.signal); result.current.retryUpload(localId);
}); });
await waitFor(() => { await waitFor(() => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading"); expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
}); });
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
act(() => { act(() => {
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal. result.current.cancelUpload(localId);
retryController.abort();
result.current.removeLocal(localId);
}); });
await waitFor(() => { await waitFor(() => {
expect(result.current.uploads).toHaveLength(0); expect(result.current.uploads).toHaveLength(0);
}); });
expect(retryController.signal.aborted).toBe(true); expect(retrySignal.aborted).toBe(true);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal); // Cancelling must not leave a successful settle path.
expect(() => resolveRetry({})).not.toThrow();
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
}); });
}); });

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
mapCompletedDateUpdateFormData,
mapWorkOrderFormToCreatePayload, mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData, mapWorkOrderFormToUpdateFormData,
} from "@/domain/work-orders/mappers/work-order-mapper"; } from "@/domain/work-orders/mappers/work-order-mapper";
@ -137,24 +136,3 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
expect(formData.getAll("CategoryIds")).toEqual([]); expect(formData.getAll("CategoryIds")).toEqual([]);
}); });
}); });
describe("mapCompletedDateUpdateFormData", () => {
it("builds FormData with only Id and CompletedDate", () => {
const formData = mapCompletedDateUpdateFormData(42, "2026-07-30");
expect(formData.get("Id")).toBe("42");
expect(formData.get("CompletedDate")).toBe("2026-07-30");
expect(formData.get("Status")).toBeNull();
expect(formData.get("WorkerOrderTitle")).toBeNull();
expect(formData.get("Description")).toBeNull();
expect(formData.getAll("ContactIds")).toEqual([]);
expect(formData.getAll("CategoryIds")).toEqual([]);
});
it("omits empty CompletedDate", () => {
const formData = mapCompletedDateUpdateFormData(1, " ");
expect(formData.get("Id")).toBe("1");
expect(formData.get("CompletedDate")).toBeNull();
});
});