diff --git a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts new file mode 100644 index 00000000..eefc3bf1 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts @@ -0,0 +1,52 @@ +export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const; +export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number]; + +export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab { + return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +// A rejection is terminal, so the archive ends with the decision and its reason +// instead of an Actions column. +const REJECTED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Rejected By", + "Rejected At", + "Rejection Reason", +] as const; + +export const UPLIFT_APPROVAL_HEADER_CELLS: Record = { + pending: PENDING_HEADER_CELLS, + approved: APPROVED_HEADER_CELLS, + rejected: REJECTED_HEADER_CELLS, +}; diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 3f52d36b..9819d2cd 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -16,14 +16,20 @@ import { formatUpliftMoney, isGrantedAmount, } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { + UPLIFT_APPROVAL_HEADER_CELLS, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { + RejectedEmptyState, + RejectedRowCells, +} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells"; import { Text } from "@/components/ui/text"; import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; -export type UpliftApprovalTab = "pending" | "approved"; - const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; @@ -236,65 +242,84 @@ function UpliftApprovalRow({ {row.requestedByVendorName || "—"} - {tab === "pending" ? ( - - - - ) : ( + {tab === "pending" && ( + <> + + + + + + + + )} + {tab === "approved" && ( <> {row.decidedByName || "—"} {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + )} - - {tab === "pending" ? ( - - ) : ( - - )} + {tab === "rejected" && } + + ); +} + +function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) { + if (tab === "rejected") return ; + if (tab === "approved") { + return ( + + No approved uplifts yet + + Uplifts you approve are archived here for reference. + + + ); + } + return ( + + No uplifts waiting for approval. + + ); +} + +// A failed queue is not an empty one: the page's error alert explains it, so the +// empty-state copy must not also claim the list has nothing in it. +function UpliftApprovalsEmptyRow({ + tab, + colSpan, + hasError, +}: { + tab: UpliftApprovalTab; + colSpan: number; + hasError: boolean; +}) { + if (hasError) return null; + return ( + + + ); } -const PENDING_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Waiting", - "Actions", -] as const; - -const APPROVED_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Approved By", - "Approved At", - "Actions", -] as const; - export function UpliftApprovalsTable({ tab, isLoading, + hasError, rows, canRevoke, onOpenDetail, @@ -306,6 +331,7 @@ export function UpliftApprovalsTable({ }: { tab: UpliftApprovalTab; isLoading: boolean; + hasError: boolean; rows: UpliftQueueItem[]; canRevoke: boolean; onOpenDetail: (row: UpliftQueueItem) => void; @@ -315,7 +341,7 @@ export function UpliftApprovalsTable({ onOpenAttachment: (row: UpliftQueueItem) => void; isDecisionPending: boolean; }) { - const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab]; return ( @@ -336,22 +362,7 @@ export function UpliftApprovalsTable({ ) : rows.length === 0 ? ( - - - {tab === "pending" ? ( - - No uplifts waiting for approval. - - ) : ( - - No approved uplifts yet - - Uplifts you approve are archived here for reference. - - - )} - - + ) : ( rows.map((row) => ( + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + {row.decisionNote || "—"} + + + + ); +} + +export function RejectedEmptyState() { + return ( + + + No rejected uplifts. + + ); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index db538443..2aaf0005 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - UpliftApprovalsTable, + UPLIFT_APPROVAL_TABS, + isUpliftApprovalTab, type UpliftApprovalTab, -} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { @@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; +import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; const PAGE_SIZE = 25; @@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: ); } +const TAB_LABELS: Record = { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +}; + +// A tab's total is only known once its queue has loaded at least once; until then the +// pill and pager must not claim a count (a loading or failed queue is not an empty one). +// A failed refetch keeps the last loaded page, so its total stays usable. +function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null { + return query.data ? query.data.totalCount : null; +} + +function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) { + return ( + + ); +} + function UpliftApprovalsTabs({ tab, - pendingCount, + counts, onChange, }: { tab: UpliftApprovalTab; - pendingCount: number; + counts: Record; onChange: (_event: unknown, value: string) => void; }) { return ( - - Pending - - - } - /> - + {UPLIFT_APPROVAL_TABS.map((value) => ( + + {TAB_LABELS[value]} + + + } + /> + ))} ); } function UpliftApprovalsPagination({ - tab, count, - pendingPage, - approvedPage, - onPendingPageChange, - onApprovedPageChange, + page, + onPageChange, }: { - tab: UpliftApprovalTab; count: number; - pendingPage: number; - approvedPage: number; - onPendingPageChange: (page: number) => void; - onApprovedPageChange: (page: number) => void; + page: number; + onPageChange: (page: number) => void; }) { return ( - tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) - } + page={page} + onPageChange={(_event, nextPage) => onPageChange(nextPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} /> @@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) { export default function UpliftQueuePage() { const [tab, setTab] = useState("pending"); - const [pendingPage, setPendingPage] = useState(0); - const [approvedPage, setApprovedPage] = useState(0); + const [pages, setPages] = useState>({ + pending: 0, + approved: 0, + rejected: 0, + }); const { user } = useAuthContext(); const canRevokeUplifts = isAdminUser(user?.userRoles); const pendingQuery = useUpliftsQueue({ status: "Pending", - page: pendingPage + 1, + page: pages.pending + 1, pageSize: PAGE_SIZE, }); const approvedQuery = useUpliftsQueue({ status: "Approved", - page: approvedPage + 1, + page: pages.approved + 1, pageSize: PAGE_SIZE, }); + const rejectedQuery = useUpliftsQueue({ + status: "Rejected", + page: pages.rejected + 1, + pageSize: PAGE_SIZE, + }); + const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery }; const controller = useUpliftApprovalController(); const { @@ -138,13 +163,18 @@ export default function UpliftQueuePage() { openDetail, } = controller; - const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const activeQuery = queries[tab]; const rows = activeQuery.data?.items ?? []; - const pendingCount = pendingQuery.data?.totalCount ?? 0; + const counts = { + pending: knownTotal(pendingQuery), + approved: knownTotal(approvedQuery), + rejected: knownTotal(rejectedQuery), + }; + const activeTotal = counts[tab]; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; const handleTabChange = (_event: unknown, value: string) => { - if (value === "pending" || value === "approved") { + if (isUpliftApprovalTab(value)) { setTab(value); } }; @@ -152,11 +182,12 @@ export default function UpliftQueuePage() { return ( - + - + {activeTotal != null && ( + setPages((current) => ({ ...current, [tab]: page }))} + /> + )} {detailItem != null && ( ), queryFn: () => upliftsApi.list(params), placeholderData: keepPreviousData, + // The Uplift Approvals page reports a failed queue inline, so the global toast + // would announce the same failure twice. + meta: { suppressErrorToast: true }, }); } diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx index 3bbf9dc1..1816bb04 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); @@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeEnabled(); fireEvent.click(revoke); @@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeDisabled(); fireEvent.mouseOver(revoke); @@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument(); expect( screen.getByText("Uplifts you approve are archived here for reference."), diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx index 30a13a51..51c52285 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx @@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => { queueState.data = queueOf(approvedItem); await renderQueue(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(amountCell()).toHaveTextContent(UPLIFT); fireEvent.click(screen.getByRole("button", { name: "Revoke" })); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx new file mode 100644 index 00000000..23323d0a --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + UpliftQueueItem, + UpliftsQueueParams, + UpliftsQueueResult, +} from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +const queueState = vi.hoisted(() => ({ + byStatus: {} as Record, + errorByStatus: {} as Record, + requestedStatuses: [] as Array, +})); + +const baseItem: UpliftQueueItem = { + id: 1, + status: "Pending", + currentNTE: 500, + requestedNTE: 750, + delta: 250, + vendorReason: "Second hydro-jetting pass on the adjoining line", + requestedAt: "2026-06-21T10:05:00Z", + requestedByVendorName: "Alyssa Dispatcher", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-9", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 1024, + dispatchNumber: "DSP-1", + poNumber: "PO-1", + vendorCompanyName: "Drain Cleaning Bros", + workOrderId: 10, + dispatchId: 20, + woNumber: "WO-25603", + site: "CHI2", + serviceName: "Drains", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: 1, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +const newestRejection: UpliftQueueItem = { + ...baseItem, + id: 31, + status: "Rejected", + woNumber: "WO-31", + decidedAt: "2026-06-21T16:40:00Z", + decidedByName: "Adam Moussa", + decisionNote: "Outside this work order's scope — raise a separate reactive WO.", + canDecide: false, +}; + +const olderRejection: UpliftQueueItem = { + ...baseItem, + id: 30, + status: "Rejected", + woNumber: "WO-30", + requestedByVendorName: "Derek Dispatcher", + decidedAt: "2026-05-26T15:05:00Z", + decidedByName: "Grace Admin", + decisionNote: "Quote is above market for this scope.", + canDecide: false, +}; + +const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" }; +const approvedItem: UpliftQueueItem = { + ...baseItem, + id: 50, + status: "Approved", + woNumber: "WO-APPROVED", + decidedAt: "2026-06-01T09:00:00Z", + decidedByName: "Adam Moussa", +}; + +function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult { + return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null }; +} + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: (params: UpliftsQueueParams) => { + queueState.requestedStatuses.push(params.status); + const data = queueState.byStatus[params.status ?? ""]; + const error = queueState.errorByStatus[params.status ?? ""] ?? null; + return { + data, + isLoading: data === undefined && error === null, + isFetching: false, + error, + refetch: vi.fn(), + }; + }, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +async function openRejectedTab() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ })); + return screen.getByRole("table", { name: "rejected uplift requests" }); +} + +describe("Uplift Approvals rejected tab", () => { + // Load the page module once up front so the first test's budget is not spent on a + // cold import of the whole route. + beforeAll(async () => { + await import("@/app/(protected)/uplifts/index"); + }, 60_000); + + beforeEach(() => { + queueState.requestedStatuses = []; + queueState.errorByStatus = {}; + queueState.byStatus = { + Pending: page([pendingItem]), + Approved: page([approvedItem]), + Rejected: page([newestRejection, olderRejection], 12), + }; + }); + + it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ }); + expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument(); + expect(queueState.requestedStatuses).toContain("Rejected"); + }); + + it("never claims a count for a queue that is still loading or failed to load", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Forbidden"); + queueState.byStatus.Approved = undefined; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedPill = screen.getByLabelText("rejected uplift count unavailable"); + expect(rejectedPill).toHaveTextContent("—"); + expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—"); + expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument(); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + }); + + it("keeps the count and pager for kept rows when a rejected refetch fails", async () => { + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).getAllByRole("row")).toHaveLength(3); + expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByText(/of 12/)).toBeInTheDocument(); + }); + + it("shows the load error instead of an empty archive when the rejected queue fails", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument(); + expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument(); + expect(screen.queryByText(/of 0/)).not.toBeInTheDocument(); + }); + + it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => { + const table = await openRejectedTab(); + + const dataRows = within(table).getAllByRole("row").slice(1); + expect(dataRows).toHaveLength(2); + expect(dataRows[0]).toHaveTextContent("WO-31"); + expect(dataRows[1]).toHaveTextContent("WO-30"); + expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument(); + expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument(); + }); + + it("shows who requested, who rejected, when, and the rejection reason inline", async () => { + const table = await openRejectedTab(); + + for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) { + expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument(); + } + const [, firstRow] = within(table).getAllByRole("row"); + expect(firstRow).toHaveTextContent("Alyssa Dispatcher"); + expect(firstRow).toHaveTextContent("Adam Moussa"); + expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt)); + expect( + within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."), + ).toBeVisible(); + }); + + it("offers no action on a rejected uplift", async () => { + const table = await openRejectedTab(); + + expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument(); + for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) { + expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument(); + } + }); + + it("shows an icon and the empty copy when nothing has been rejected", async () => { + queueState.byStatus.Rejected = page([]); + const table = await openRejectedTab(); + + expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument(); + expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument(); + expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx new file mode 100644 index 00000000..f71eb8d4 --- /dev/null +++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx @@ -0,0 +1,40 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error, success: vi.fn() }, +})); + +afterEach(() => { + vi.restoreAllMocks(); + toastMocks.error.mockReset(); +}); + +describe("useUpliftsQueue", () => { + it("does not show the global error toast, since the approvals page reports the failure inline", async () => { + vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue")); + // The real app query client runs the QueryCache's global onError handler, which + // toasts unless the query opts out. + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + + const { result } = renderHook( + () => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +});