mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
3 commits
7feea529a4
...
b317cdc718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b317cdc718 | ||
|
|
35d34866af | ||
|
|
24c09ff103 |
6 changed files with 364 additions and 0 deletions
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect } from "react";
|
||||
import { nextSearchPageIfOutOfRange } from "@/domain/work-orders/utils/day-pagination";
|
||||
|
||||
type UseSyncOutOfRangeSearchPageInput = {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
setPage: (page: number) => void;
|
||||
};
|
||||
|
||||
/** Keeps the requested search page inside the last valid page after the result set shrinks. */
|
||||
export function useSyncOutOfRangeSearchPage(input: UseSyncOutOfRangeSearchPageInput) {
|
||||
const { enabled, ready, page, pageSize, totalCount, setPage } = input;
|
||||
const nextPage = nextSearchPageIfOutOfRange({ page, pageSize, totalCount });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !ready || nextPage == null) {
|
||||
return;
|
||||
}
|
||||
setPage(nextPage);
|
||||
}, [enabled, ready, nextPage, setPage]);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
|
|
@ -104,6 +105,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
);
|
||||
|
||||
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: advActive,
|
||||
ready: !isLoading,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
totalCount: scopeTotalCount,
|
||||
setPage: filters.setPage,
|
||||
});
|
||||
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
|
||||
const pager = pickBoardPager({
|
||||
advActive,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,21 @@ export function resolveSearchPager(input: { page: number; pageSize: number; tota
|
|||
};
|
||||
}
|
||||
|
||||
export function nextSearchPageIfOutOfRange(input: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
}): number | null {
|
||||
if (input.totalCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const pageCount = totalPageCount(input.totalCount, asDayPageSize(input.pageSize));
|
||||
if (input.page < pageCount) {
|
||||
return null;
|
||||
}
|
||||
return pageCount - 1;
|
||||
}
|
||||
|
||||
export function pickBoardPager(input: {
|
||||
advActive: boolean;
|
||||
isLoading: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||
|
||||
describe("useSyncOutOfRangeSearchPage", () => {
|
||||
it("sets the last valid page when a search shrinks to one page", () => {
|
||||
const setPage = vi.fn();
|
||||
renderHook(() =>
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: true,
|
||||
ready: true,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalCount: 40,
|
||||
setPage,
|
||||
}),
|
||||
);
|
||||
expect(setPage).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("does not sync while the search query is loading", () => {
|
||||
const setPage = vi.fn();
|
||||
renderHook(() =>
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: true,
|
||||
ready: false,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalCount: 40,
|
||||
setPage,
|
||||
}),
|
||||
);
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not sync when the result set is empty", () => {
|
||||
const setPage = vi.fn();
|
||||
renderHook(() =>
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: true,
|
||||
ready: true,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalCount: 0,
|
||||
setPage,
|
||||
}),
|
||||
);
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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 (
|
||||
<div>
|
||||
<div data-testid="harness-state">
|
||||
{`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount} page=${filters.page}`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
filters.setPageSize(PAGE_SIZE);
|
||||
filters.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, rescheduled: true });
|
||||
}}
|
||||
>
|
||||
Apply advanced filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const row = tableData.rows[0];
|
||||
if (row) {
|
||||
void mutations.patchFieldAsync(row.id, { serviceNotes: "after-mutation" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save row patch
|
||||
</button>
|
||||
<WoTable
|
||||
tableData={tableData}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
weekMonday={filters.weekMonday}
|
||||
onNavigateToDay={filters.navigateToDay}
|
||||
onOpenSlideOver={() => undefined}
|
||||
onOpenMedia={() => undefined}
|
||||
onPatch={(id, patch) => {
|
||||
void mutations.patchFieldAsync(id, patch);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(<AdvSearchPageSyncHarness />, { 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);
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
asDayPageSize,
|
||||
findItemPageIndex,
|
||||
formatDayPaginationSummary,
|
||||
nextSearchPageIfOutOfRange,
|
||||
pageIndexForItem,
|
||||
paginateItems,
|
||||
pickBoardPager,
|
||||
|
|
@ -137,3 +138,21 @@ describe("day pagination utils (SH-241)", () => {
|
|||
expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextSearchPageIfOutOfRange", () => {
|
||||
it("returns the last valid page when a search shrinks to one page", () => {
|
||||
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 40 })).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a still-valid second page alone", () => {
|
||||
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 60 })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pull an empty result set onto page 1", () => {
|
||||
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the last valid page alone", () => {
|
||||
expect(nextSearchPageIfOutOfRange({ page: 0, pageSize: 50, totalCount: 40 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue