diff --git a/frontend/src/__tests__/utils/resultWindow.test.js b/frontend/src/__tests__/utils/resultWindow.test.js new file mode 100644 index 0000000000..541a082b4f --- /dev/null +++ b/frontend/src/__tests__/utils/resultWindow.test.js @@ -0,0 +1,66 @@ +import { + DEFAULT_MAX_RESULT_WINDOW, + browsableItemCount, + pageCount, +} from "../../utils/resultWindow"; + +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("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(pageCount(147080, 12004, 20)).toBe(600); + }); + + it("falls back to the default window when window is missing or invalid", () => { + 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("returns 0 for invalid perPage", () => { + expect(pageCount(100, 10000, 0)).toBe(0); + expect(pageCount(100, 10000, undefined)).toBe(0); + }); +}); + +describe("browsableItemCount", () => { + it("returns totalItems when under the window", () => { + expect(browsableItemCount(500, 10000, 20)).toBe(500); + }); + + 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("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 4857b37601..7e52d7f8fd 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, perPage); + const browsablePageCount = pageCount(totalItems, maxResultWindow, perPage); const [filterText, setFilterText] = useState(""); const [goToPage, setGoToPage] = useState(""); const perPageOptions = [10, 20, 30, 40, 50]; @@ -189,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"); @@ -247,7 +262,7 @@ const MyDataTable = observer( if ( !isNaN(pageNumber) && pageNumber >= 0 && - pageNumber < Math.ceil(totalItems / perPage) + pageNumber < browsablePageCount ) { onPageChange(pageNumber); } @@ -257,6 +272,7 @@ const MyDataTable = observer( const newPerPage = Number(event.target.value); if (!isNaN(newPerPage) && newPerPage > 0) { onPerPageChange(newPerPage); + onPageChange(0); } }; @@ -620,10 +636,19 @@ const MyDataTable = observer( className="d-flex justify-content-center align-items-center" style={{ color: "white" }} > - + {error ? ( + + ) : ( + + )} ) : ( @@ -659,13 +684,14 @@ const MyDataTable = observer( ))} + {browsablePageCount > 0 && ( "} breakLabel={"..."} breakClassName={"page-item"} breakLinkClassName={"page-link"} - pageCount={Math.ceil(totalItems / perPage)} + pageCount={browsablePageCount} marginPagesDisplayed={2} pageRangeDisplayed={2} onPageChange={handlePageChange} @@ -677,8 +703,9 @@ const MyDataTable = observer( nextClassName={"page-item"} nextLinkClassName={"page-link"} activeClassName={"active-page"} - forcePage={page} + forcePage={Math.min(page, browsablePageCount - 1)} /> + )} + {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..0d9fda9f95 100644 --- a/frontend/src/pages/SearchPages/EncounterSearch.jsx +++ b/frontend/src/pages/SearchPages/EncounterSearch.jsx @@ -23,6 +23,7 @@ const EncounterSearch = observer(() => { 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" }); @@ -67,6 +68,7 @@ const EncounterSearch = observer(() => { const { data: encounterData, loading, + error: encounterError, refetch, } = useFilterEncounters({ queries: store.appliedFilters, @@ -255,6 +257,12 @@ const EncounterSearch = observer(() => { 10, ), ); + setSearchIdMaxResultWindow( + parseInt( + get(response, ["headers", "x-wildbook-max-result-window"], "0"), + 10, + ), + ); setFilterPanel(false); }) .catch((error) => { @@ -333,6 +341,10 @@ const EncounterSearch = observer(() => { searchText={intl.formatMessage({ id: "SEARCH" })} tableData={sortedEncounters} totalItems={queryID ? totalItems : totalEncounters} + maxResultWindow={ + queryID ? searchIdMaxResultWindow : 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..e5cb62e636 --- /dev/null +++ b/frontend/src/utils/resultWindow.js @@ -0,0 +1,32 @@ +// OpenSearch refuses to fetch hits past index.max_result_window (a from + size +// ceiling), so pagination must only offer pages whose whole range stays 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; + +function effectiveWindow(maxResultWindow) { + return maxResultWindow > 0 ? maxResultWindow : DEFAULT_MAX_RESULT_WINDOW; +} + +export function pageCount(totalItems, maxResultWindow, perPage) { + if (!perPage || perPage < 1) return 0; + 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); +}