mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 15:09:02 +00:00
fix(work-orders): persist extra media placement
This commit is contained in:
parent
74e5c58e5d
commit
a54fcaae9f
4 changed files with 146 additions and 31 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
import {
|
import {
|
||||||
isCompletionSurfaceMedia,
|
isCompletionSurfaceMedia,
|
||||||
isExtrasSurfaceMedia,
|
isExtrasSurfaceMedia,
|
||||||
|
type MediaSurfaceOwnership,
|
||||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||||
|
|
||||||
type UseSlideOverMediaStateArgs = {
|
type UseSlideOverMediaStateArgs = {
|
||||||
|
|
@ -37,34 +38,83 @@ function dropOwnedId(previous: Set<string>, id: string): Set<string> {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
|
||||||
|
|
||||||
|
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
|
||||||
|
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
|
||||||
|
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||||
|
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
|
||||||
|
return {
|
||||||
|
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
|
||||||
|
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOwnership(
|
||||||
|
workOrderId: string | number | undefined,
|
||||||
|
ownership: MediaSurfaceOwnership,
|
||||||
|
): void {
|
||||||
|
if (workOrderId == null) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
completion: [...ownership.completionOwnedIds],
|
||||||
|
extra: [...ownership.extraOwnedIds],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useSlideOverMediaState({
|
export function useSlideOverMediaState({
|
||||||
detailForRow,
|
detailForRow,
|
||||||
uploadMediaMutation,
|
uploadMediaMutation,
|
||||||
deleteMediaMutation,
|
deleteMediaMutation,
|
||||||
categorizeMediaMutation,
|
categorizeMediaMutation,
|
||||||
}: UseSlideOverMediaStateArgs) {
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const workOrderId = detailForRow?.info.id;
|
||||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
const [completionOwnedIds, setCompletionOwnedIds] = useState<Set<string>>(() => new Set());
|
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||||
const [extraOwnedIds, setExtraOwnedIds] = useState<Set<string>>(() => new Set());
|
loadOwnership(workOrderId),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOwnershipState(loadOwnership(workOrderId));
|
||||||
|
}, [workOrderId]);
|
||||||
|
|
||||||
|
const updateOwnership = useCallback(
|
||||||
|
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
|
||||||
|
setOwnershipState((current) => {
|
||||||
|
const next = update(current);
|
||||||
|
saveOwnership(workOrderId, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workOrderId],
|
||||||
|
);
|
||||||
|
|
||||||
const persistCompletionUpload = useCallback(
|
const persistCompletionUpload = useCallback(
|
||||||
async (input: UploadWorkOrderMediaInput) => {
|
async (input: UploadWorkOrderMediaInput) => {
|
||||||
const item = await uploadMediaMutation.mutateAsync(input);
|
const item = await uploadMediaMutation.mutateAsync(input);
|
||||||
setCompletionOwnedIds((previous) => addOwnedId(previous, item.id));
|
updateOwnership((current) => ({
|
||||||
setExtraOwnedIds((previous) => dropOwnedId(previous, item.id));
|
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||||
|
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||||
|
}));
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
[uploadMediaMutation],
|
[updateOwnership, uploadMediaMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||||
|
|
||||||
const ownership = useMemo(
|
|
||||||
() => ({ completionOwnedIds, extraOwnedIds }),
|
|
||||||
[completionOwnedIds, extraOwnedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const completionMedia = useMemo(
|
const completionMedia = useMemo(
|
||||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||||
[uploads, ownership],
|
[uploads, ownership],
|
||||||
|
|
@ -96,8 +146,10 @@ export function useSlideOverMediaState({
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||||
setExtraOwnedIds((previous) => addOwnedId(previous, item.id));
|
updateOwnership((current) => ({
|
||||||
setCompletionOwnedIds((previous) => dropOwnedId(previous, item.id));
|
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||||
|
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setMediaBatchPending(false);
|
setMediaBatchPending(false);
|
||||||
|
|
@ -110,14 +162,20 @@ export function useSlideOverMediaState({
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (category === "Extra") {
|
categorizeMediaMutation.mutate(
|
||||||
setExtraOwnedIds((previous) => addOwnedId(previous, mediaId));
|
{ mediaId, category, workOrderVersion },
|
||||||
setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId));
|
{
|
||||||
} else {
|
onSuccess: () => {
|
||||||
setExtraOwnedIds((previous) => dropOwnedId(previous, mediaId));
|
updateOwnership((current) => ({
|
||||||
setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId));
|
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||||
}
|
extraOwnedIds:
|
||||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
category === "Extra"
|
||||||
|
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||||
|
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMedia = (id: string) => {
|
const removeMedia = (id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function MediaUploaderGridItemMeta({
|
||||||
{needsCategory && (
|
{needsCategory && (
|
||||||
<select
|
<select
|
||||||
aria-label={`Category for ${file.name}`}
|
aria-label={`Category for ${file.name}`}
|
||||||
value={file.purpose ?? ""}
|
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value as UploadableMediaCategory;
|
const value = event.target.value as UploadableMediaCategory;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MediaUploaderGridItemMeta } from "@/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
const extraPhoto: MediaFile = {
|
||||||
|
id: "10",
|
||||||
|
name: "site.jpg",
|
||||||
|
fileType: "image",
|
||||||
|
url: "https://cdn.example.com/site.jpg",
|
||||||
|
category: "Photo",
|
||||||
|
purpose: "Extra",
|
||||||
|
isLegacy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MediaUploaderGridItemMeta", () => {
|
||||||
|
it("allows an Extra photo to be explicitly assigned to Extra Docs", () => {
|
||||||
|
const onCategorize = vi.fn();
|
||||||
|
render(
|
||||||
|
<MediaUploaderGridItemMeta
|
||||||
|
file={extraPhoto}
|
||||||
|
readOnly={false}
|
||||||
|
pending={false}
|
||||||
|
onCategorize={onCategorize}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const category = screen.getByLabelText("Category for site.jpg");
|
||||||
|
expect(category).toHaveValue("");
|
||||||
|
|
||||||
|
fireEvent.change(category, { target: { value: "Extra" } });
|
||||||
|
|
||||||
|
expect(onCategorize).toHaveBeenCalledWith("10", "Extra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
@ -85,6 +85,10 @@ function idleMutation() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useSlideOverMediaState completion filter", () => {
|
describe("useSlideOverMediaState completion filter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||||
const detail = detailWithMedia([
|
const detail = detailWithMedia([
|
||||||
{
|
{
|
||||||
|
|
@ -214,7 +218,7 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
|
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves an Extra photo to Extra Docs after assigning Extra", () => {
|
it("keeps an Extra photo in Extra Docs after assigning Extra and remounting", () => {
|
||||||
const detail = detailWithMedia([
|
const detail = detailWithMedia([
|
||||||
{
|
{
|
||||||
id: "10",
|
id: "10",
|
||||||
|
|
@ -225,14 +229,16 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
isLegacy: false,
|
isLegacy: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const categorizeMutate = vi.fn();
|
const categorizeMutate = vi.fn((_input: unknown, options?: { onSuccess?: () => void }) =>
|
||||||
|
options?.onSuccess?.(),
|
||||||
|
);
|
||||||
const categorize = {
|
const categorize = {
|
||||||
mutate: categorizeMutate,
|
mutate: categorizeMutate,
|
||||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||||
isPending: false,
|
isPending: false,
|
||||||
} as never;
|
} as never;
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result, unmount } = renderHook(() =>
|
||||||
useSlideOverMediaState({
|
useSlideOverMediaState({
|
||||||
detailForRow: detail,
|
detailForRow: detail,
|
||||||
uploadMediaMutation: idleMutation(),
|
uploadMediaMutation: idleMutation(),
|
||||||
|
|
@ -250,10 +256,26 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
|
|
||||||
expect(result.current.completionMedia).toHaveLength(0);
|
expect(result.current.completionMedia).toHaveLength(0);
|
||||||
expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
||||||
expect(categorizeMutate).toHaveBeenCalledWith({
|
expect(categorizeMutate).toHaveBeenCalledWith(
|
||||||
mediaId: "10",
|
{
|
||||||
category: "Extra",
|
mediaId: "10",
|
||||||
workOrderVersion: "1",
|
category: "Extra",
|
||||||
});
|
workOrderVersion: "1",
|
||||||
|
},
|
||||||
|
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
const remounted = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detail,
|
||||||
|
uploadMediaMutation: idleMutation(),
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(remounted.result.current.completionMedia).toHaveLength(0);
|
||||||
|
expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue