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);
+}