From d901f1cb7783b130378c61cd90f9f9e5f2a8da21 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Fri, 10 Jul 2026 16:35:44 -0700 Subject: [PATCH 1/3] Cap search pagination at the OpenSearch result window; surface search errors - DataTable offers only pages inside index.max_result_window (via the X-Wildbook-Max-Result-Window header when available, defaulting to 10000) instead of terminal page links the backend can never serve; when capped, a localized notice explains that only the first N results are browsable - the "Go to page" input honors the same ceiling - an error state now renders a localized "search failed" message instead of a misleading "No results found" empty table - useFilterEncounters exposes the window header and no longer retries 4xx responses (deterministic failures were retried twice per click) - new UI copy localized in de/en/es/fr/it Fixes #1681 Co-Authored-By: Claude Fable 5 --- .../src/__tests__/utils/resultWindow.test.js | 48 +++++++++++++++++++ frontend/src/components/DataTable.jsx | 43 ++++++++++++++--- frontend/src/locale/de.json | 2 + frontend/src/locale/en.json | 2 + frontend/src/locale/es.json | 2 + frontend/src/locale/fr.json | 2 + frontend/src/locale/it.json | 2 + .../models/encounters/useFilterEncounters.js | 12 ++++- .../src/pages/SearchPages/EncounterSearch.jsx | 3 ++ frontend/src/utils/resultWindow.js | 16 +++++++ 10 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 frontend/src/__tests__/utils/resultWindow.test.js create mode 100644 frontend/src/utils/resultWindow.js diff --git a/frontend/src/__tests__/utils/resultWindow.test.js b/frontend/src/__tests__/utils/resultWindow.test.js new file mode 100644 index 0000000000..fdb99236cb --- /dev/null +++ b/frontend/src/__tests__/utils/resultWindow.test.js @@ -0,0 +1,48 @@ +import { + DEFAULT_MAX_RESULT_WINDOW, + browsableItemCount, + pageCount, +} from "../../utils/resultWindow"; + +describe("browsableItemCount", () => { + it("returns totalItems when under the window", () => { + expect(browsableItemCount(500, 10000)).toBe(500); + }); + + it("caps totalItems at the window", () => { + // the production case: 147k hits, only the first window is fetchable + expect(browsableItemCount(147080, 10000)).toBe(10000); + }); + + it("respects a per-index window larger than the default", () => { + expect(browsableItemCount(147080, 12004)).toBe(12004); + }); + + it("falls back to the default window when window is missing or invalid", () => { + expect(browsableItemCount(20000, undefined)).toBe(DEFAULT_MAX_RESULT_WINDOW); + expect(browsableItemCount(20000, 0)).toBe(DEFAULT_MAX_RESULT_WINDOW); + expect(browsableItemCount(20000, -1)).toBe(DEFAULT_MAX_RESULT_WINDOW); + expect(browsableItemCount(20000, NaN)).toBe(DEFAULT_MAX_RESULT_WINDOW); + }); + + it("tolerates missing totalItems", () => { + expect(browsableItemCount(undefined, 10000)).toBe(0); + expect(browsableItemCount(0, 10000)).toBe(0); + }); +}); + +describe("pageCount", () => { + it("computes pages over the browsable range only", () => { + // 147k results at 20/page must offer 500 pages, not 7354 + expect(pageCount(147080, 10000, 20)).toBe(500); + }); + + it("computes pages normally for small result sets", () => { + expect(pageCount(45, 10000, 20)).toBe(3); + }); + + it("returns 0 for invalid perPage", () => { + expect(pageCount(100, 10000, 0)).toBe(0); + expect(pageCount(100, 10000, undefined)).toBe(0); + }); +}); diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index 4857b37601..f9db80ff04 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -13,6 +13,7 @@ import GalleryView from "../pages/SearchPages/searchResultTabs/GalleryView"; import Select from "react-select"; import MainButton from "./MainButton"; import { useSiteSettings } from "../SiteSettingsContext"; +import { browsableItemCount, pageCount } from "../utils/resultWindow"; const customStyles = { rows: { @@ -33,6 +34,8 @@ const MyDataTable = observer( title = "", columnNames = [], totalItems = 0, + maxResultWindow = undefined, + error = null, tableData = [], searchText = "", page, @@ -48,6 +51,10 @@ const MyDataTable = observer( setExportModalOpen = () => {}, }) => { const [data, setData] = useState([]); + // OpenSearch cannot serve hits past index.max_result_window - offer only pages + // that are actually fetchable, even when the total hit count is larger + const browsableItems = browsableItemCount(totalItems, maxResultWindow); + const browsablePageCount = pageCount(totalItems, maxResultWindow, perPage); const [filterText, setFilterText] = useState(""); const [goToPage, setGoToPage] = useState(""); const perPageOptions = [10, 20, 30, 40, 50]; @@ -247,7 +254,7 @@ const MyDataTable = observer( if ( !isNaN(pageNumber) && pageNumber >= 0 && - pageNumber < Math.ceil(totalItems / perPage) + pageNumber < browsablePageCount ) { onPageChange(pageNumber); } @@ -620,10 +627,19 @@ const MyDataTable = observer( className="d-flex justify-content-center align-items-center" style={{ color: "white" }} > - + {error ? ( + + ) : ( + + )} ) : ( @@ -665,7 +681,7 @@ const MyDataTable = observer( breakLabel={"..."} breakClassName={"page-item"} breakLinkClassName={"page-link"} - pageCount={Math.ceil(totalItems / perPage)} + pageCount={browsablePageCount} marginPagesDisplayed={2} pageRangeDisplayed={2} onPageChange={handlePageChange} @@ -696,6 +712,21 @@ const MyDataTable = observer( + {totalItems > browsableItems && ( + + + + )} )} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index bdabdc79c0..e2205bec58 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -141,6 +141,8 @@ "TOTAL_ITEMS": "Posten insgesamt", "PER_PAGE": "Pro Seite", "PAGE": "Seite", + "RESULTS_WINDOW_CAPPED": "Nur die ersten {limit} von {total} Ergebnissen können durchsucht werden. Grenzen Sie Ihre Suche ein, um den Rest zu sehen.", + "SEARCH_RESULTS_ERROR": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut oder passen Sie Ihre Filter an.", "GO_TO": "Gehe zu", "GO": "Gehe", "ENCOUNTER_SEARCH_RESULTS": "Ergebnisse der Begegnungssuche", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 71c57893c6..9b80e0bb88 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -141,6 +141,8 @@ "TOTAL_ITEMS": "Total Items", "PER_PAGE": "Per Page", "PAGE": "Page", + "RESULTS_WINDOW_CAPPED": "Only the first {limit} of {total} results can be browsed. Narrow your search to see the rest.", + "SEARCH_RESULTS_ERROR": "Search failed. Please try again or adjust your filters.", "GO_TO": "Go to", "GO": "Go", "ENCOUNTER_SEARCH_RESULTS": "Encounter Search Results", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 92a495fcdb..c656391bb9 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -117,6 +117,8 @@ "TOTAL_ITEMS": "Total de Ítems", "PER_PAGE": "Por Página", "PAGE": "Página", + "RESULTS_WINDOW_CAPPED": "Solo se pueden explorar los primeros {limit} de {total} resultados. Acote su búsqueda para ver el resto.", + "SEARCH_RESULTS_ERROR": "La búsqueda falló. Inténtelo de nuevo o ajuste sus filtros.", "GO_TO": "Ir a", "GO": "Ir", "ENCOUNTER_SEARCH_RESULTS": "Resultados de Búsqueda de Encuentros", diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index ce9f8ce054..f030dda3e8 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -141,6 +141,8 @@ "TOTAL_ITEMS": "Articles Totals", "PER_PAGE": "Par Page", "PAGE": "Page", + "RESULTS_WINDOW_CAPPED": "Seuls les {limit} premiers résultats sur {total} peuvent être parcourus. Affinez votre recherche pour voir le reste.", + "SEARCH_RESULTS_ERROR": "La recherche a échoué. Veuillez réessayer ou ajuster vos filtres.", "GO_TO": "Aller à", "GO": "Aller", "ENCOUNTER_SEARCH_RESULTS": "Résultats de Recherche de Rencontres", diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index 0e71b776e5..61fa62c384 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -141,6 +141,8 @@ "TOTAL_ITEMS": "Totale Articoli", "PER_PAGE": "Per Pagina", "PAGE": "Pagina", + "RESULTS_WINDOW_CAPPED": "È possibile sfogliare solo i primi {limit} risultati su {total}. Restringi la ricerca per vedere il resto.", + "SEARCH_RESULTS_ERROR": "Ricerca non riuscita. Riprova o modifica i filtri.", "GO_TO": "Vai a", "GO": "Vai", "ENCOUNTER_SEARCH_RESULTS": "Risultati della Ricerca di Incontri", diff --git a/frontend/src/models/encounters/useFilterEncounters.js b/frontend/src/models/encounters/useFilterEncounters.js index 3dd446d709..4c5c40e9b5 100644 --- a/frontend/src/models/encounters/useFilterEncounters.js +++ b/frontend/src/models/encounters/useFilterEncounters.js @@ -55,9 +55,14 @@ export default function useFilterEncounters({ queries, params = {} }) { get(result, ["data", "headers", "x-wildbook-total-hits"], "0"), 10, ); + const maxResultWindow = parseInt( + get(result, ["data", "headers", "x-wildbook-max-result-window"], "0"), + 10, + ); return { resultCount, + maxResultWindow, results: get(result, ["data", "data", "hits"], []), searchQueryId: get( result, @@ -68,7 +73,12 @@ export default function useFilterEncounters({ queries, params = {} }) { }; }, queryOptions: { - retry: 2, + // retry transport/server errors only - a 4xx (e.g. pagination window + // exceeded) is deterministic and re-sending it just repeats the failure + retry: (failureCount, err) => { + const status = err?.response?.status; + return (!status || status >= 500) && failureCount < 2; + }, enable: false, }, }); diff --git a/frontend/src/pages/SearchPages/EncounterSearch.jsx b/frontend/src/pages/SearchPages/EncounterSearch.jsx index a7dc4bcc7d..bdd40d139d 100644 --- a/frontend/src/pages/SearchPages/EncounterSearch.jsx +++ b/frontend/src/pages/SearchPages/EncounterSearch.jsx @@ -67,6 +67,7 @@ const EncounterSearch = observer(() => { const { data: encounterData, loading, + error: encounterError, refetch, } = useFilterEncounters({ queries: store.appliedFilters, @@ -333,6 +334,8 @@ const EncounterSearch = observer(() => { searchText={intl.formatMessage({ id: "SEARCH" })} tableData={sortedEncounters} totalItems={queryID ? totalItems : totalEncounters} + maxResultWindow={encounterData?.maxResultWindow} + error={queryID ? null : encounterError} page={queryID ? searchIdResultPage : page} perPage={queryID ? searchIdResultPerPage : perPage} onPageChange={queryID ? setSearchIdResultPage : setPage} diff --git a/frontend/src/utils/resultWindow.js b/frontend/src/utils/resultWindow.js new file mode 100644 index 0000000000..9925697be1 --- /dev/null +++ b/frontend/src/utils/resultWindow.js @@ -0,0 +1,16 @@ +// OpenSearch refuses to fetch hits past index.max_result_window (from + size ceiling), +// so pagination must only offer pages inside that window even when the total hit count +// is larger. The backend reports the per-index ceiling via the +// X-Wildbook-Max-Result-Window response header; default to the OpenSearch default. +export const DEFAULT_MAX_RESULT_WINDOW = 10000; + +export function browsableItemCount(totalItems, maxResultWindow) { + const windowLimit = + maxResultWindow > 0 ? maxResultWindow : DEFAULT_MAX_RESULT_WINDOW; + return Math.min(totalItems || 0, windowLimit); +} + +export function pageCount(totalItems, maxResultWindow, perPage) { + if (!perPage || perPage < 1) return 0; + return Math.ceil(browsableItemCount(totalItems, maxResultWindow) / perPage); +} From a6836ff8e79345bb944c301af1555e69d8c998d1 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Fri, 10 Jul 2026 16:50:42 -0700 Subject: [PATCH 2/3] Address review: full-page window math, page-state clamping - pageCount uses floor for capped result sets so the final offered page's from+size never straddles the window (window 10000 at 30/page previously offered page 334 = from 9990 + size 30 = 10020, refused by the backend); partial last pages remain for result sets inside the window - browsableItemCount reports the actually reachable item count (pages x perPage when capped) so the capped notice is accurate - changing per-page resets to the first page, and a clamp effect snaps the page back whenever the browsable page count drops below it; forcePage is clamped to the cap Co-Authored-By: Claude Fable 5 --- .../src/__tests__/utils/resultWindow.test.js | 64 ++++++++++++------- frontend/src/components/DataTable.jsx | 13 +++- frontend/src/utils/resultWindow.js | 34 +++++++--- 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/frontend/src/__tests__/utils/resultWindow.test.js b/frontend/src/__tests__/utils/resultWindow.test.js index fdb99236cb..541a082b4f 100644 --- a/frontend/src/__tests__/utils/resultWindow.test.js +++ b/frontend/src/__tests__/utils/resultWindow.test.js @@ -4,45 +4,63 @@ import { pageCount, } from "../../utils/resultWindow"; -describe("browsableItemCount", () => { - it("returns totalItems when under the window", () => { - expect(browsableItemCount(500, 10000)).toBe(500); +describe("pageCount", () => { + it("computes pages over the browsable range only", () => { + // 147k results at 20/page must offer 500 pages, not 7354 + expect(pageCount(147080, 10000, 20)).toBe(500); + }); + + it("never offers a final page that straddles the window", () => { + // at 30/page, page 334 would request from=9990&size=30 (=10020 > 10000); + // only 333 full pages fit inside the window + expect(pageCount(147080, 10000, 30)).toBe(333); }); - it("caps totalItems at the window", () => { - // the production case: 147k hits, only the first window is fetchable - expect(browsableItemCount(147080, 10000)).toBe(10000); + it("keeps the partial last page for result sets inside the window", () => { + expect(pageCount(45, 10000, 20)).toBe(3); + // exactly at the window: ceil is safe because from+size never exceeds it + expect(pageCount(10000, 10000, 30)).toBe(334); }); it("respects a per-index window larger than the default", () => { - expect(browsableItemCount(147080, 12004)).toBe(12004); + expect(pageCount(147080, 12004, 20)).toBe(600); }); it("falls back to the default window when window is missing or invalid", () => { - expect(browsableItemCount(20000, undefined)).toBe(DEFAULT_MAX_RESULT_WINDOW); - expect(browsableItemCount(20000, 0)).toBe(DEFAULT_MAX_RESULT_WINDOW); - expect(browsableItemCount(20000, -1)).toBe(DEFAULT_MAX_RESULT_WINDOW); - expect(browsableItemCount(20000, NaN)).toBe(DEFAULT_MAX_RESULT_WINDOW); + expect(pageCount(20000, undefined, 20)).toBe(500); + expect(pageCount(20000, 0, 20)).toBe(500); + expect(pageCount(20000, -1, 20)).toBe(500); + expect(pageCount(20000, NaN, 20)).toBe(500); }); - it("tolerates missing totalItems", () => { - expect(browsableItemCount(undefined, 10000)).toBe(0); - expect(browsableItemCount(0, 10000)).toBe(0); + it("returns 0 for invalid perPage", () => { + expect(pageCount(100, 10000, 0)).toBe(0); + expect(pageCount(100, 10000, undefined)).toBe(0); }); }); -describe("pageCount", () => { - it("computes pages over the browsable range only", () => { - // 147k results at 20/page must offer 500 pages, not 7354 - expect(pageCount(147080, 10000, 20)).toBe(500); +describe("browsableItemCount", () => { + it("returns totalItems when under the window", () => { + expect(browsableItemCount(500, 10000, 20)).toBe(500); }); - it("computes pages normally for small result sets", () => { - expect(pageCount(45, 10000, 20)).toBe(3); + it("reports the actually reachable item count when capped", () => { + expect(browsableItemCount(147080, 10000, 20)).toBe(10000); + // at 30/page only 333 pages x 30 = 9990 items are reachable + expect(browsableItemCount(147080, 10000, 30)).toBe(9990); }); - it("returns 0 for invalid perPage", () => { - expect(pageCount(100, 10000, 0)).toBe(0); - expect(pageCount(100, 10000, undefined)).toBe(0); + it("respects a per-index window larger than the default", () => { + expect(browsableItemCount(147080, 12004, 20)).toBe(12000); + }); + + it("tolerates missing totalItems and invalid perPage", () => { + expect(browsableItemCount(undefined, 10000, 20)).toBe(0); + expect(browsableItemCount(0, 10000, 20)).toBe(0); + expect(browsableItemCount(20000, 10000, 0)).toBe(10000); + }); + + it("exports the OpenSearch default window", () => { + expect(DEFAULT_MAX_RESULT_WINDOW).toBe(10000); }); }); diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index f9db80ff04..df782e5927 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -53,7 +53,7 @@ const MyDataTable = observer( const [data, setData] = useState([]); // OpenSearch cannot serve hits past index.max_result_window - offer only pages // that are actually fetchable, even when the total hit count is larger - const browsableItems = browsableItemCount(totalItems, maxResultWindow); + const browsableItems = browsableItemCount(totalItems, maxResultWindow, perPage); const browsablePageCount = pageCount(totalItems, maxResultWindow, perPage); const [filterText, setFilterText] = useState(""); const [goToPage, setGoToPage] = useState(""); @@ -196,6 +196,14 @@ const MyDataTable = observer( [columnNames], ); + // if the browsable page count shrinks below the current page (window/per-page + // changed), snap back to the last reachable page + useEffect(() => { + if (browsablePageCount > 0 && page >= browsablePageCount) { + onPageChange(browsablePageCount - 1); + } + }, [browsablePageCount, page, onPageChange]); + const params = new URLSearchParams(window.location.search); const individualIDExact = params.get("individualIDExact"); const calendar = params.get("calendar"); @@ -264,6 +272,7 @@ const MyDataTable = observer( const newPerPage = Number(event.target.value); if (!isNaN(newPerPage) && newPerPage > 0) { onPerPageChange(newPerPage); + onPageChange(0); } }; @@ -693,7 +702,7 @@ const MyDataTable = observer( nextClassName={"page-item"} nextLinkClassName={"page-link"} activeClassName={"active-page"} - forcePage={page} + forcePage={Math.min(page, Math.max(browsablePageCount - 1, 0))} /> 0 ? maxResultWindow : DEFAULT_MAX_RESULT_WINDOW; - return Math.min(totalItems || 0, windowLimit); +function effectiveWindow(maxResultWindow) { + return maxResultWindow > 0 ? maxResultWindow : DEFAULT_MAX_RESULT_WINDOW; } export function pageCount(totalItems, maxResultWindow, perPage) { if (!perPage || perPage < 1) return 0; - return Math.ceil(browsableItemCount(totalItems, maxResultWindow) / perPage); + const total = totalItems || 0; + const windowLimit = effectiveWindow(maxResultWindow); + + // inside the window a partial last page is fine (from + size stays <= window) + if (total <= windowLimit) return Math.ceil(total / perPage); + // capped: only FULL pages fit - a ceil'd last page would straddle the window + // (e.g. window 10000 at 30/page: from=9990&size=30 = 10020 is refused) + return Math.floor(windowLimit / perPage); +} + +export function browsableItemCount(totalItems, maxResultWindow, perPage) { + const total = totalItems || 0; + const windowLimit = effectiveWindow(maxResultWindow); + + if (total <= windowLimit) return total; + const pages = pageCount(total, maxResultWindow, perPage); + + return pages > 0 ? pages * perPage : Math.min(total, windowLimit); } From 8e5980cf1636f851b99ce26fb225b0c0425fa59d Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Fri, 10 Jul 2026 16:59:31 -0700 Subject: [PATCH 3/3] Address review: per-branch window on query-ID path, no paginator at zero pages - the query-ID search now reads x-wildbook-max-result-window from its OWN response and the table receives the branch-appropriate limit, so stored searches on indexes with a raised window are no longer capped at 10k (and smaller windows can no longer offer invalid pages) - the paginator is not rendered when the browsable page count is zero, avoiding react-paginate's controlled-page warning during initial load Co-Authored-By: Claude Fable 5 --- frontend/src/components/DataTable.jsx | 4 +++- frontend/src/pages/SearchPages/EncounterSearch.jsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index df782e5927..7e52d7f8fd 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -684,6 +684,7 @@ const MyDataTable = observer( ))} + {browsablePageCount > 0 && ( "} @@ -702,8 +703,9 @@ const MyDataTable = observer( nextClassName={"page-item"} nextLinkClassName={"page-link"} activeClassName={"active-page"} - forcePage={Math.min(page, Math.max(browsablePageCount - 1, 0))} + forcePage={Math.min(page, browsablePageCount - 1)} /> + )} { const schemas = useEncounterSearchSchemas(); const theme = React.useContext(ThemeColorContext); const [totalItems, setTotalItems] = useState(0); + const [searchIdMaxResultWindow, setSearchIdMaxResultWindow] = useState(0); const [searchIdResultPage, setSearchIdResultPage] = useState(0); const [searchIdResultPerPage, setSearchIdResultPerPage1] = useState(20); const [sort, setSort] = useState({ sortname: "date", sortorder: "desc" }); @@ -256,6 +257,12 @@ const EncounterSearch = observer(() => { 10, ), ); + setSearchIdMaxResultWindow( + parseInt( + get(response, ["headers", "x-wildbook-max-result-window"], "0"), + 10, + ), + ); setFilterPanel(false); }) .catch((error) => { @@ -334,7 +341,9 @@ const EncounterSearch = observer(() => { searchText={intl.formatMessage({ id: "SEARCH" })} tableData={sortedEncounters} totalItems={queryID ? totalItems : totalEncounters} - maxResultWindow={encounterData?.maxResultWindow} + maxResultWindow={ + queryID ? searchIdMaxResultWindow : encounterData?.maxResultWindow + } error={queryID ? null : encounterError} page={queryID ? searchIdResultPage : page} perPage={queryID ? searchIdResultPerPage : perPage}