From 24c09ff1039cb7a14fdf9255819f03aa1fd75e90 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 25 Aug 2026 13:57:56 -0300 Subject: [PATCH] fix(work-orders): sync search page when the result set shrinks --- .../use-sync-out-of-range-search-page.ts | 24 +++++++++ .../_hooks/use-work-orders-table-data.ts | 9 ++++ .../work-orders/utils/day-pagination.ts | 15 ++++++ ...use-sync-out-of-range-search-page.test.tsx | 50 +++++++++++++++++++ .../work-orders/utils/day-pagination.test.ts | 19 +++++++ 5 files changed, 117 insertions(+) create mode 100644 src/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page.ts create mode 100644 src/test/app/(protected)/workorders/use-sync-out-of-range-search-page.test.tsx diff --git a/src/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page.ts b/src/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page.ts new file mode 100644 index 00000000..27085c3f --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page.ts @@ -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]); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 8f111937..f9847220 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -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, diff --git a/src/domain/work-orders/utils/day-pagination.ts b/src/domain/work-orders/utils/day-pagination.ts index 74d34c3b..322ed1da 100644 --- a/src/domain/work-orders/utils/day-pagination.ts +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -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; diff --git a/src/test/app/(protected)/workorders/use-sync-out-of-range-search-page.test.tsx b/src/test/app/(protected)/workorders/use-sync-out-of-range-search-page.test.tsx new file mode 100644 index 00000000..c9f466a0 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-sync-out-of-range-search-page.test.tsx @@ -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(); + }); +}); diff --git a/src/test/domain/work-orders/utils/day-pagination.test.ts b/src/test/domain/work-orders/utils/day-pagination.test.ts index 75d0e8c1..dba28fcb 100644 --- a/src/test/domain/work-orders/utils/day-pagination.test.ts +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -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(); + }); +});