diff --git a/src/app/v/[token]/dispatch/_components/checklist-section.tsx b/src/app/v/[token]/dispatch/_components/checklist-section.tsx index 4c1acb2e..f966dd93 100644 --- a/src/app/v/[token]/dispatch/_components/checklist-section.tsx +++ b/src/app/v/[token]/dispatch/_components/checklist-section.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Text } from "@/components/ui/text"; import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers"; import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal"; @@ -19,15 +20,19 @@ export function ChecklistSection({ onItemUpdated, }: ChecklistSectionProps) { const updateItem = useUpdateVendorPortalChecklistItem(token, dispatchId); + // updateItem.isError only reflects the latest call on the shared mutation observer, so it + // can't tell an earlier failed toggle from a later one that succeeded. Track it locally. + const [failed, setFailed] = useState(false); const toggle = (item: VendorPortalChecklistItem) => { if (locked) return; + setFailed(false); // mutateAsync resolves per call, unlike mutate's onSuccess which only fires for the // latest call on this shared observer — needed so two quick toggles both report back. void updateItem .mutateAsync({ itemId: item.id, isCompleted: !item.isCompleted }) .then((updated) => onItemUpdated(updated)) - .catch(() => undefined); + .catch(() => setFailed(true)); }; if (!items.length) { @@ -36,7 +41,7 @@ export function ChecklistSection({ return (
- + Unable to update the checklist. Please try again. {items.map((item) => ( diff --git a/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts b/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts index 1d21a8a9..8771df52 100644 --- a/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts +++ b/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts @@ -25,6 +25,8 @@ export function useUpdateVendorPortalChecklistItem( return useMutation({ mutationFn: ({ itemId, isCompleted }: UpdateChecklistItemInput) => vendorPortalApi.updateChecklistItem(token, dispatchId, itemId, isCompleted), + // Each caller already renders its own inline failure copy. + meta: { suppressErrorToast: true }, }); } @@ -43,6 +45,7 @@ export function useAddVendorPortalSignoff( signatureMethod: string; signoffType: string; }) => vendorPortalApi.addSignoff(token, dispatchId, payload), + meta: { suppressErrorToast: true }, }); } @@ -52,6 +55,7 @@ export function useAddVendorPortalComment( ): UseMutationResult { return useMutation({ mutationFn: (commentText: string) => vendorPortalApi.addComment(token, dispatchId, commentText), + meta: { suppressErrorToast: true }, }); } @@ -62,6 +66,7 @@ export function useRequestVendorUplift( return useMutation({ mutationFn: (payload: VendorUpliftRequestBody) => vendorPortalApi.requestUplift(token, dispatchId, payload), + meta: { suppressErrorToast: true }, }); } @@ -72,6 +77,7 @@ export function useWithdrawVendorUplift( return useMutation({ mutationFn: (requestId: string | number) => vendorPortalApi.withdrawUplift(token, dispatchId, requestId), + meta: { suppressErrorToast: true }, }); } @@ -87,5 +93,6 @@ export function useReviseVendorUplift( return useMutation({ mutationFn: ({ requestId, payload }: ReviseUpliftInput) => vendorPortalApi.reviseUplift(token, dispatchId, requestId, payload), + meta: { suppressErrorToast: true }, }); } diff --git a/src/test/app/v/checklist-section.test.tsx b/src/test/app/v/checklist-section.test.tsx index e0e1013e..533b1dd9 100644 --- a/src/test/app/v/checklist-section.test.tsx +++ b/src/test/app/v/checklist-section.test.tsx @@ -92,4 +92,40 @@ describe("ChecklistSection", () => { expect(onItemUpdated.mock.calls.map(([updated]) => updated.id)).toEqual([1, 2]), ); }); + + it("keeps the inline error visible when one of two in-flight toggles fails and the other succeeds", async () => { + const a: VendorPortalChecklistItem = { id: 1, itemText: "Check filters", isCompleted: false }; + const b: VendorPortalChecklistItem = { id: 2, itemText: "Check belts", isCompleted: false }; + let rejectA: (error: Error) => void = () => {}; + let resolveB: () => void = () => {}; + let aRequested = false; + let bRequested = false; + vi.spyOn(vendorPortalApi, "updateChecklistItem").mockImplementation( + (_token, _dispatchId, itemId) => + itemId === 1 + ? new Promise((_resolve, reject) => { + rejectA = reject; + aRequested = true; + }) + : new Promise((resolve) => { + resolveB = () => resolve({ ...b, isCompleted: true }); + bRequested = true; + }), + ); + const onItemUpdated = vi.fn(); + renderSection({ items: [a, b] }, onItemUpdated); + + const [boxA, boxB] = screen.getAllByRole("checkbox"); + fireEvent.click(boxA); + fireEvent.click(boxB); + await waitFor(() => expect(aRequested && bRequested).toBe(true)); + + rejectA(new Error("boom")); + resolveB(); + + await waitFor(() => expect(onItemUpdated).toHaveBeenCalledWith({ ...b, isCompleted: true })); + expect( + await screen.findByText("Unable to update the checklist. Please try again."), + ).toBeInTheDocument(); + }); }); diff --git a/src/test/app/v/comments-section.test.tsx b/src/test/app/v/comments-section.test.tsx index 5ef0c60f..de350524 100644 --- a/src/test/app/v/comments-section.test.tsx +++ b/src/test/app/v/comments-section.test.tsx @@ -3,10 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal"; +import { createAppQueryClient } from "@/lib/query/query-client"; import { renderWithProviders } from "@/test/test-utils"; +const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("react-toastify", () => ({ toast: toastMocks })); + afterEach(() => { vi.restoreAllMocks(); + toastMocks.success.mockReset(); + toastMocks.error.mockReset(); }); function renderSection(comments: VendorPortalComment[] = [], onAdded = vi.fn()) { @@ -19,6 +25,17 @@ function renderSection(comments: VendorPortalComment[] = [], onAdded = vi.fn()) }; } +/** The app's real query client, so the global mutation error toast is live. */ +function renderSectionWithAppQueryClient(onAdded = vi.fn()) { + return { + onAdded, + ...renderWithProviders( + , + { withAuth: false, queryClient: createAppQueryClient() }, + ), + }; +} + describe("CommentsSection", () => { it("shows a placeholder when there are no comments yet", () => { renderSection(); @@ -70,4 +87,18 @@ describe("CommentsSection", () => { screen.queryByText("Unable to post your comment. Please try again."), ).not.toBeInTheDocument(); }); + + it("shows only the inline error on a failed post, not a duplicate global toast", async () => { + vi.spyOn(vendorPortalApi, "addComment").mockRejectedValue(new Error("network down")); + renderSectionWithAppQueryClient(); + + const textarea = screen.getByPlaceholderText("Add a comment for the dispatcher…"); + fireEvent.change(textarea, { target: { value: "Looks good" } }); + fireEvent.click(screen.getByRole("button", { name: "Post Comment" })); + + expect( + await screen.findByText("Unable to post your comment. Please try again."), + ).toBeInTheDocument(); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/v/signoff-section.test.tsx b/src/test/app/v/signoff-section.test.tsx index 3c2b8b25..e14e87ea 100644 --- a/src/test/app/v/signoff-section.test.tsx +++ b/src/test/app/v/signoff-section.test.tsx @@ -2,13 +2,22 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; +import { createAppQueryClient } from "@/lib/query/query-client"; import { renderWithProviders } from "@/test/test-utils"; +const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("react-toastify", () => ({ toast: toastMocks })); + afterEach(() => { vi.restoreAllMocks(); + toastMocks.success.mockReset(); + toastMocks.error.mockReset(); }); -function renderSection(overrides: Partial> = {}) { +function renderSection( + overrides: Partial> = {}, + options: { useAppQueryClient?: boolean } = {}, +) { return renderWithProviders( , - { withAuth: false }, + { + withAuth: false, + ...(options.useAppQueryClient ? { queryClient: createAppQueryClient() } : {}), + }, ); } +function submitTypedSignoff(name = "Jane Doe") { + fireEvent.click(screen.getByRole("radio", { name: "Typed" })); + const [nameInput, signatureInput] = screen.getAllByRole("textbox"); + fireEvent.change(nameInput, { target: { value: name } }); + fireEvent.change(signatureInput, { target: { value: name } }); + fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" })); +} + describe("SignoffSection", () => { it("shows status-neutral locked copy when submission is locked", () => { renderSection({ submissionLocked: true }); @@ -70,11 +90,7 @@ describe("SignoffSection", () => { const onAdded = vi.fn(); renderSection({ onAdded }); - fireEvent.click(screen.getByRole("radio", { name: "Typed" })); - const [nameInput, signatureInput] = screen.getAllByRole("textbox"); - fireEvent.change(nameInput, { target: { value: "Jane Doe" } }); - fireEvent.change(signatureInput, { target: { value: "Jane Doe" } }); - fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" })); + submitTypedSignoff(); await waitFor(() => expect(onAdded).toHaveBeenCalled()); expect(vendorPortalApi.addSignoff).toHaveBeenCalledWith("portal-token", 9, { @@ -89,14 +105,22 @@ describe("SignoffSection", () => { vi.spyOn(vendorPortalApi, "addSignoff").mockRejectedValue(new Error("boom")); renderSection(); - fireEvent.click(screen.getByRole("radio", { name: "Typed" })); - const [nameInput, signatureInput] = screen.getAllByRole("textbox"); - fireEvent.change(nameInput, { target: { value: "Jane Doe" } }); - fireEvent.change(signatureInput, { target: { value: "Jane Doe" } }); - fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" })); + submitTypedSignoff(); expect( await screen.findByText("Unable to submit the signoff. Please try again."), ).toBeInTheDocument(); }); + + it("shows only the inline error on a failed submit, not a duplicate global toast", async () => { + vi.spyOn(vendorPortalApi, "addSignoff").mockRejectedValue(new Error("boom")); + renderSection({}, { useAppQueryClient: true }); + + submitTypedSignoff(); + + expect( + await screen.findByText("Unable to submit the signoff. Please try again."), + ).toBeInTheDocument(); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/v/uplift-requests-section.test.tsx b/src/test/app/v/uplift-requests-section.test.tsx new file mode 100644 index 00000000..a1c3c360 --- /dev/null +++ b/src/test/app/v/uplift-requests-section.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; +import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; +import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; +import { createAppQueryClient } from "@/lib/query/query-client"; +import { renderWithProviders } from "@/test/test-utils"; + +const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("react-toastify", () => ({ toast: toastMocks })); + +const pendingRequest: VendorPortalUpliftRequest = { + id: 501, + status: "Pending", + requestedNTE: 750, + currentNTE: 500, + raisedByVendor: true, +}; + +beforeEach(() => { + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + toastMocks.success.mockReset(); + toastMocks.error.mockReset(); +}); + +function renderSection( + overrides: Partial> = {}, + options: { useAppQueryClient?: boolean } = {}, +) { + return renderWithProviders( + , + { + withAuth: false, + ...(options.useAppQueryClient ? { queryClient: createAppQueryClient() } : {}), + }, + ); +} + +describe("UpliftRequestsSection withdraw", () => { + it("withdraws a pending request and refreshes the dispatch", async () => { + vi.spyOn(vendorPortalApi, "withdrawUplift").mockResolvedValue(undefined); + const onChanged = vi.fn().mockResolvedValue(undefined); + renderSection({ onChanged }); + + fireEvent.click(screen.getByRole("button", { name: "Withdraw request" })); + + await waitFor(() => + expect(vendorPortalApi.withdrawUplift).toHaveBeenCalledWith( + "portal-token", + 9, + pendingRequest.id, + ), + ); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it("does nothing when the confirm dialog is dismissed", () => { + const withdrawUplift = vi.spyOn(vendorPortalApi, "withdrawUplift"); + vi.spyOn(window, "confirm").mockReturnValue(false); + renderSection(); + + fireEvent.click(screen.getByRole("button", { name: "Withdraw request" })); + + expect(withdrawUplift).not.toHaveBeenCalled(); + }); + + it("shows an inline error when withdrawing fails", async () => { + vi.spyOn(vendorPortalApi, "withdrawUplift").mockRejectedValue(new Error("boom")); + renderSection(); + + fireEvent.click(screen.getByRole("button", { name: "Withdraw request" })); + + expect( + await screen.findByText("Unable to withdraw the uplift request. Please try again."), + ).toBeInTheDocument(); + }); + + it("shows only the inline error on a failed withdraw, not a duplicate global toast", async () => { + vi.spyOn(vendorPortalApi, "withdrawUplift").mockRejectedValue(new Error("boom")); + renderSection({}, { useAppQueryClient: true }); + + fireEvent.click(screen.getByRole("button", { name: "Withdraw request" })); + + expect( + await screen.findByText("Unable to withdraw the uplift request. Please try again."), + ).toBeInTheDocument(); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +});