Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions frontend/src/__tests__/utils/resultWindow.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 49 additions & 7 deletions frontend/src/components/DataTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -33,6 +34,8 @@ const MyDataTable = observer(
title = "",
columnNames = [],
totalItems = 0,
maxResultWindow = undefined,
error = null,
tableData = [],
searchText = "",
page,
Expand All @@ -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];
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -247,7 +262,7 @@ const MyDataTable = observer(
if (
!isNaN(pageNumber) &&
pageNumber >= 0 &&
pageNumber < Math.ceil(totalItems / perPage)
pageNumber < browsablePageCount
) {
onPageChange(pageNumber);
}
Expand All @@ -257,6 +272,7 @@ const MyDataTable = observer(
const newPerPage = Number(event.target.value);
if (!isNaN(newPerPage) && newPerPage > 0) {
onPerPageChange(newPerPage);
onPageChange(0);
}
};

Expand Down Expand Up @@ -620,10 +636,19 @@ const MyDataTable = observer(
className="d-flex justify-content-center align-items-center"
style={{ color: "white" }}
>
<FormattedMessage
id="NO_RESULTS_FOUND"
defaultMessage={"No results found"}
/>
{error ? (
<FormattedMessage
id="SEARCH_RESULTS_ERROR"
defaultMessage={
"Search failed. Please try again or adjust your filters."
}
/>
) : (
<FormattedMessage
id="NO_RESULTS_FOUND"
defaultMessage={"No results found"}
/>
)}
</div>
) : (
<Row className="mt-3 d-flex justify-content-center align-items-center">
Expand Down Expand Up @@ -659,13 +684,14 @@ const MyDataTable = observer(
))}
</Form.Control>
</InputGroup>
{browsablePageCount > 0 && (
<ReactPaginate
previousLabel={"<"}
nextLabel={">"}
breakLabel={"..."}
breakClassName={"page-item"}
breakLinkClassName={"page-link"}
pageCount={Math.ceil(totalItems / perPage)}
pageCount={browsablePageCount}
marginPagesDisplayed={2}
pageRangeDisplayed={2}
onPageChange={handlePageChange}
Expand All @@ -677,8 +703,9 @@ const MyDataTable = observer(
nextClassName={"page-item"}
nextLinkClassName={"page-link"}
activeClassName={"active-page"}
forcePage={page}
forcePage={Math.min(page, browsablePageCount - 1)}
/>
)}
<InputGroup
className="ms-3"
style={{ width: "180px", whiteSpace: "nowrap" }}
Expand All @@ -696,6 +723,21 @@ const MyDataTable = observer(
</Button>
</InputGroup>
</Col>
{totalItems > browsableItems && (
<Col
xs={12}
className="mt-2 d-flex justify-content-center"
style={{ color: "white" }}
>
<FormattedMessage
id="RESULTS_WINDOW_CAPPED"
defaultMessage={
"Only the first {limit} of {total} results can be browsed. Narrow your search to see the rest."
}
values={{ limit: browsableItems, total: totalItems }}
/>
</Col>
)}
</Row>
)}
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locale/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locale/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locale/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locale/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/models/encounters/useFilterEncounters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
});
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/pages/SearchPages/EncounterSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -67,6 +68,7 @@ const EncounterSearch = observer(() => {
const {
data: encounterData,
loading,
error: encounterError,
refetch,
} = useFilterEncounters({
queries: store.appliedFilters,
Expand Down Expand Up @@ -255,6 +257,12 @@ const EncounterSearch = observer(() => {
10,
),
);
setSearchIdMaxResultWindow(
parseInt(
get(response, ["headers", "x-wildbook-max-result-window"], "0"),
10,
),
);
setFilterPanel(false);
})
.catch((error) => {
Expand Down Expand Up @@ -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}
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/utils/resultWindow.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading