diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx new file mode 100644 index 00000000..1ef0734c --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx @@ -0,0 +1,247 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +const { searchBoard, getBoard, patchBoardField, emptyList } = vi.hoisted(() => ({ + searchBoard: vi.fn(), + getBoard: vi.fn(), + patchBoardField: vi.fn(), + emptyList: [] as const, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + getBoard: (...args: unknown[]) => getBoard(...args), + patchBoardField: (...args: unknown[]) => patchBoardField(...args), + }, +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: emptyList }), + useWorkOrderVendors: () => ({ data: emptyList }), +})); + +vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({ + useLocationSites: () => ({ data: emptyList }), +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({ + useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }), +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; + +const PAGE_SIZE = 25; +const INITIAL_TOTAL = 26; +const SHRUNK_TOTAL = 20; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "CLV2", + type: "PM", + severity: null, + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-08-17", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Cleveland, OH", + locationId: "", + pm: "Backflow", + serviceNotes: "", + extraServices: [], + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "—", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + isAddOn: false, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + vendorId: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function emptyBoardResult() { + return { + items: [] as WorkOrderTableRow[], + scheduled: [] as WorkOrderTableRow[], + unscheduled: [] as WorkOrderTableRow[], + totalCount: 0, + returnedCount: 0, + page: 0, + pageSize: PAGE_SIZE, + }; +} + +function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) { + const items = rows.slice(page * pageSize, page * pageSize + pageSize); + return { + items, + scheduled: items, + unscheduled: [] as WorkOrderTableRow[], + totalCount: rows.length, + returnedCount: items.length, + page, + pageSize, + }; +} + +function pagesAfter(callCount: number): number[] { + return searchBoard.mock.calls.slice(callCount).map((call) => { + const params = call[0] as WorkOrderBoardSearchQueryParams; + return params.page ?? 0; + }); +} + +function AdvSearchPageSyncHarness() { + const filters = useWorkOrdersListFilters(); + const tableData = useWorkOrdersTableData(filters); + const mutations = useWorkOrderTableMutations({ + onPatch: tableData.patchRow, + clearPatch: tableData.clearRowPatch, + getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), + }); + + return ( +
+
+ {`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount} page=${filters.page}`} +
+ + + undefined} + onOpenMedia={() => undefined} + onPatch={(id, patch) => { + void mutations.patchFieldAsync(id, patch); + }} + /> +
+ ); +} + +describe("advanced search page sync after mutation", () => { + let serverRows: WorkOrderTableRow[]; + + beforeEach(() => { + serverRows = Array.from({ length: INITIAL_TOTAL }, (_, index) => + makeRow({ id: `wo-${index}`, woNumber: `WO-${index}` }), + ); + searchBoard.mockReset(); + getBoard.mockReset(); + patchBoardField.mockReset(); + getBoard.mockResolvedValue(emptyBoardResult()); + searchBoard.mockImplementation(async (params: WorkOrderBoardSearchQueryParams) => + pagedResult(serverRows, params.page ?? 0, params.pageSize ?? PAGE_SIZE), + ); + patchBoardField.mockImplementation(async (id: string | number) => { + serverRows = serverRows.slice(0, SHRUNK_TOTAL); + return makeRow({ + id, + woNumber: `WO-${String(id).replace("wo-", "")}`, + serviceNotes: "after-mutation", + }); + }); + }); + + it("refetches the last valid search page after a mutation shrinks results to one page", async () => { + renderWithProviders(, { withAuth: false }); + + fireEvent.click(screen.getByRole("button", { name: "Apply advanced filters" })); + await waitFor(() => { + expect(screen.getByTestId("harness-state")).toHaveTextContent( + "adv=true rows=25 total=26 page=0", + ); + }); + expect(screen.getByText("WO-0")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + await waitFor(() => { + expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1"); + expect(screen.getByText("WO-25")).toBeInTheDocument(); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + }); + + const callsBeforeMutation = searchBoard.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Save row patch" })); + + await waitFor(() => { + expect(patchBoardField).toHaveBeenCalled(); + expect(pagesAfter(callsBeforeMutation)).toContain(1); + expect(pagesAfter(callsBeforeMutation)).toContain(0); + }); + + await waitFor(() => { + expect(screen.getByTestId("harness-state")).toHaveTextContent( + "adv=true rows=20 total=20 page=0", + ); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + expect(screen.getByText("WO-0")).toBeInTheDocument(); + expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); + expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + }, 15000); +});