diff --git a/ui-rs/src/components/PatronRequests/Filters.css b/ui-rs/src/components/PatronRequests/Filters.css
new file mode 100644
index 0000000..730a5f7
--- /dev/null
+++ b/ui-rs/src/components/PatronRequests/Filters.css
@@ -0,0 +1,17 @@
+/* Match for the typed text in a peer facet option. Used rather than Stripes'
+ * Highlighter component as it's insufficiently configurable to reflect actual
+ * matching semantics. */
+.mark { background-color: var(--highlighter-fill); }
+
+/* Peer facet option row: label left, facet count right. flexGrow rather than
+ * width:100% leaves the row's built-in add/remove marker its space instead of
+ * colliding with the count. */
+.peerOption {
+ display: flex;
+ justify-content: space-between;
+ flex-grow: 1;
+ gap: 1rem;
+ padding-right: 0.75rem;
+}
+
+.peerSymbol { opacity: 0.7; }
diff --git a/ui-rs/src/components/PatronRequests/Filters.js b/ui-rs/src/components/PatronRequests/Filters.js
index a6ff6d0..99cc988 100644
--- a/ui-rs/src/components/PatronRequests/Filters.js
+++ b/ui-rs/src/components/PatronRequests/Filters.js
@@ -5,7 +5,9 @@ import {
Accordion,
AccordionSet,
FilterAccordionHeader,
+ Loading,
} from '@folio/stripes/components';
+import css from './Filters.css';
// Extract dates from stored CQL fragment, e.g. "created_at>=2024-01-01 and created_at<=2024-12-31"
const parseDateValues = (filterStrings) => {
@@ -16,7 +18,35 @@ const parseDateValues = (filterStrings) => {
};
};
-const Filters = ({ activeFilters, filterHandlers, options }) => {
+// The server prefix-matches name and symbol independently, so marking exactly the matched
+// prefix means an unmarked field is the signal that it isn't why the row came back. Stripes'
+// Highlighter can't express that, since it marks every occurrence and cannot anchor. The
+// term is trimmed the same way the route trims it before putting it in the CQL.
+const markPrefix = (text, term) => {
+ const trimmed = term.trim();
+ if (!trimmed || !text.toLowerCase().startsWith(trimmed.toLowerCase())) return text;
+ return <>{text.slice(0, trimmed.length)}{text.slice(trimmed.length)}>;
+};
+
+// Peer option row: name (when known) followed inline by the muted parenthesised symbol,
+// with the facet count right-aligned. With no name only the symbol shows, so duplicate
+// names stay unambiguous.
+const peerOptionFormatter = ({ option, searchTerm }) => {
+ const mark = (text) => markPrefix(text, searchTerm || '');
+ return (
+
+
+ {option.name && <>{mark(option.name)} >}
+ ({mark(option.symbol)})
+
+ {option.count != null && {option.count}}
+
+ );
+};
+
+const Filters = ({ activeFilters, filterHandlers, options, peerFacet = {} }) => {
+ const { name: peerFilterName, ready: peerFacetReady, loading: peerFacetLoading, onType: onPeerFilterType } = peerFacet;
+
const onChangeHandler = (group) => {
filterHandlers.state({
...activeFilters,
@@ -24,6 +54,11 @@ const Filters = ({ activeFilters, filterHandlers, options }) => {
});
};
+ // Under asyncFiltering this is a notification, not a filter: MultiSelection ignores the
+ // return value and renders dataOptions as given, so all it does is report the typed text
+ // (debounced by 300ms on its side) so the route can requery.
+ const asyncPeerFilter = (filterText) => onPeerFilterType(filterText || '');
+
return (
<>
{
onChange={onChangeHandler}
/>
+ {peerFilterName && (
+ }
+ id={peerFilterName}
+ name={peerFilterName}
+ separator={false}
+ header={FilterAccordionHeader}
+ displayClearButton={activeFilters?.[peerFilterName]?.length > 0}
+ onClearFilter={() => filterHandlers.clearGroup(peerFilterName)}
+ >
+ {/* Wait for the first option list before mounting the select: with no options
+ to show, MultiSelection renders its empty message and a spinner together
+ (MultiSelectOptionsList), so an open menu would read "no matching options"
+ for the whole initial load. */}
+ {peerFacetReady ? (
+ option.label}
+ showLoading={peerFacetLoading}
+ asyncFiltering
+ filter={asyncPeerFilter}
+ />
+ ) : (
+
+ )}
+
+ )}
{
+const PatronRequests = ({
+ requestsQuery,
+ perPage,
+ filterOptions,
+ peerFacet,
+ children,
+}) => {
const appName = useContext(AppNameContext);
const history = useHistory();
const intl = useIntl();
@@ -151,6 +157,7 @@ const PatronRequests = ({ requestsQuery, perPage, filterOptions, children }) =>
activeFilters={activeFilters.state}
filterHandlers={getFilterHandlers()}
options={filterOptions}
+ peerFacet={peerFacet}
/>
{requestsQuery.isLoading && }
diff --git a/ui-rs/src/routes/PatronRequestsRoute.js b/ui-rs/src/routes/PatronRequestsRoute.js
index 03ba2ee..1b61829 100644
--- a/ui-rs/src/routes/PatronRequestsRoute.js
+++ b/ui-rs/src/routes/PatronRequestsRoute.js
@@ -1,14 +1,32 @@
-import React from 'react';
+import React, { useState } from 'react';
import { useInfiniteQuery } from 'react-query';
import { useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom';
+import queryString from 'query-string';
import { useOkapiKy, useOkapiQuery } from '@projectreshare/stripes-reshare';
import PatronRequests from '../components/PatronRequests';
import { ServiceType, ServiceLevel } from '../constants/iso18626';
-import { buildPatronRequestsCql } from '../util/buildPatronRequestsCql';
+import { buildPatronRequestsCql, buildFacetOptionsCql } from '../util/buildPatronRequestsCql';
const PER_PAGE = 100;
+// The meaningful peer axis is the opposite side: in the request (borrowing) app you
+// filter by who is supplying you; in the supply (lending) app, by who is requesting.
+const PEER_FACET = {
+ request: { filterName: 'supplier', symbolField: 'supplier_symbol', nameField: 'supplier_name' },
+ supply: { filterName: 'requester', symbolField: 'requester_symbol', nameField: 'requester_name' },
+};
+
+// Selected values for one filter group, read straight off the URL.
+const selectedFilterValues = (location, filterName) => {
+ const filters = queryString.parse(location.search).filters || '';
+ const prefix = `${filterName}.`;
+ return filters
+ .split(',')
+ .filter((pair) => pair.startsWith(prefix))
+ .map((pair) => pair.slice(prefix.length));
+};
+
const PatronRequestsRoute = ({ appName, children }) => {
const intl = useIntl();
const ky = useOkapiKy();
@@ -18,6 +36,23 @@ const PatronRequestsRoute = ({ appName, children }) => {
const cql = buildPatronRequestsCql(location);
+ const { filterName: peerFilterName, symbolField, nameField } = PEER_FACET[appName];
+ const selectedPeers = selectedFilterValues(location, peerFilterName);
+
+ // Typeahead text for the peer facet, which narrows the options query server-side (the
+ // debounce is MultiSelection's, see Filters). The filter subtree is keyed on
+ // location.search, so any search or filter change remounts the input empty; the term is
+ // stamped with the location it was typed at and dropped when that no longer matches, so
+ // it can't go on narrowing off text nothing on screen shows. Setting state during render
+ // is the React-blessed way to do this; the stale render is discarded before commit.
+ const [peer, setPeer] = useState({ search: location.search, term: '' });
+ if (peer.search !== location.search) setPeer({ search: location.search, term: '' });
+ const trimmedTerm = peer.term.trim();
+ // location.search here is the one this render closed over, not the current one:
+ // MultiSelection captures the callback at mount, so a debounced call landing after a
+ // navigation stamps the old location and the check above discards it.
+ const setPeerTerm = (term) => setPeer({ search: location.search, term });
+
const prQuery = useInfiniteQuery(
{
queryKey: ['broker/patron_requests', `@projectreshare/${appName}`, cql],
@@ -37,11 +72,53 @@ const PatronRequestsRoute = ({ appName, children }) => {
}
);
+ // The peer option list, narrowed by the typeahead term when there is one. Keyed (via
+ // useOkapiQuery's searchParams) on the CQL, so react-query's cache does the bookkeeping:
+ // each term is its own entry, and clearing the term or selecting a peer returns to the
+ // untyped entry already in cache rather than refetching.
+ // useOkapiQuery (not raw ky) so a failed facet load hits the error boundary: an
+ // unreachable facet service means the broker is down, not a facet with no values.
+ const facetCql = buildFacetOptionsCql(
+ location,
+ peerFilterName,
+ { nameField, symbolField, term: trimmedTerm },
+ );
+ const peerFacetQuery = useOkapiQuery(
+ 'broker/patron_requests',
+ {
+ searchParams: { limit: 0, side, facets: symbolField, ...(facetCql ? { cql: facetCql } : {}) },
+ // Hold the previous key's rows while the next request is in flight. MultiSelection
+ // renders its empty message and its spinner off the same `renderedItems.length === 0`
+ // (MultiSelectOptionsList), so an empty list mid-request would read as "no matches".
+ keepPreviousData: true,
+ staleTime: 2 * 60 * 1000,
+ cacheTime: 10 * 60 * 1000,
+ },
+ );
+
const stateModelQuery = useOkapiQuery('broker/state_model/models/returnables', {
staleTime: 30 * 60 * 1000,
cacheTime: 8 * 60 * 60 * 1000,
});
+ const facetValues = peerFacetQuery.data?.about?.facets
+ ?.find((f) => f.name === symbolField)?.values || [];
+
+ // Facet row -> option. `value` stays the raw symbol (URL/CQL state); `label` is the
+ // unambiguous display string. Every active selection missing from the current source
+ // (ranked beyond the server's top-N, or a narrowed-away value) is appended so its
+ // chip still renders.
+ const peerOptions = facetValues.map((row) => {
+ const name = row.label ?? '';
+ const symbol = row.value;
+ const label = name ? `${name} (${symbol})` : `(${symbol})`;
+ return { value: symbol, name, symbol, label, count: row.count };
+ });
+ const knownSymbols = new Set(peerOptions.map((o) => o.value));
+ selectedPeers
+ .filter((v) => !knownSymbols.has(v))
+ .forEach((v) => peerOptions.push({ value: v, name: '', symbol: v, label: `(${v})` }));
+
const filterOptions = {
needsAttention: [{ label: intl.formatMessage({ id: 'ui-rs.needsAttention' }), value: 'true' }],
hasCost: [{ label: intl.formatMessage({ id: 'ui-rs.hasCost' }), value: 'true' }],
@@ -53,6 +130,7 @@ const PatronRequestsRoute = ({ appName, children }) => {
state: (stateModelQuery.data?.states || [])
.filter(s => s.side === stateSide)
.map(s => ({ label: s.display, value: s.name })),
+ [peerFilterName]: peerOptions,
};
return (
@@ -60,6 +138,12 @@ const PatronRequestsRoute = ({ appName, children }) => {
requestsQuery={prQuery}
perPage={PER_PAGE}
filterOptions={filterOptions}
+ peerFacet={{
+ name: peerFilterName,
+ ready: peerFacetQuery.isSuccess,
+ loading: peerFacetQuery.isFetching,
+ onType: setPeerTerm,
+ }}
>
{children}
diff --git a/ui-rs/src/routes/PatronRequestsRoute.test.js b/ui-rs/src/routes/PatronRequestsRoute.test.js
index d89fefc..9031081 100644
--- a/ui-rs/src/routes/PatronRequestsRoute.test.js
+++ b/ui-rs/src/routes/PatronRequestsRoute.test.js
@@ -53,10 +53,15 @@ const LocationSearch = () => {
return ;
};
-const patronRequestUrls = () => mockOkapi.mock.calls
- .map(([url]) => decodeURIComponent(url))
+const patronRequestUrls = () => mockOkapi.calledUrls()
+ .map((url) => decodeURIComponent(url))
.filter((u) => u.startsWith('broker/patron_requests'));
+// The paged list query, as distinct from the peer facet's own options query, which shares
+// the same pathname. Identified by what the options query is rather than by PER_PAGE:
+// limit=0 (aggregates only, no rows) is inherent to it, whereas the page size is a tunable.
+const listQueryUrls = () => patronRequestUrls().filter((u) => !u.includes('limit=0'));
+
describe('PatronRequestsRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -70,7 +75,8 @@ describe('PatronRequestsRoute', () => {
await waitFor(() => {
expect(patronRequestUrls().some((u) => u.includes('terminal_state'))).toBe(true);
});
- const url = patronRequestUrls().at(-1);
+ // The peer facet's options query shares this pathname, so pick out the list query.
+ const url = listQueryUrls().at(-1);
expect(url).toContain('side=borrowing');
// terminal filter maps to terminal_state with the broker-specific single '='
// operator (filters2cql's default would be '==').
@@ -116,3 +122,154 @@ describe('PatronRequestsRoute', () => {
// CheckboxFilter/SearchAndSortQuery behaviour, not ours. Driving it also floods
// the test with act() warnings from the navigate-and-refetch, for little signal.
});
+
+// ---- Peer facet filter ---------------------------------------------------------
+
+// A patron_requests body carrying facets only when the request asked for them, as the broker
+// does. Responses are matched on pathname alone (see okapiKyMock — the query string holds
+// generated CQL that tests should not have to spell out), so without this the mock would keep
+// serving an option list even if the list query stopped requesting one, and every test below
+// would pass on a facet it never asked for.
+const facetBody = (url, facetValues) => ({
+ items: [],
+ about: {
+ count: 0,
+ ...(url.includes('facets=supplier_symbol')
+ ? { facets: [{ name: 'supplier_symbol', values: facetValues }] }
+ : {}),
+ },
+});
+
+const peerResponses = (facetValues) => ({
+ 'broker/patron_requests': (url) => facetBody(url, facetValues),
+ 'broker/state_model/models/returnables': { states: [] },
+});
+
+// Open the supplier peer accordion and return its combobox filter input. The input only
+// appears once the base facet has loaded (until then a spinner shows), so wait for it.
+const peerInputSelector = 'input[aria-labelledby="accordion-toggle-button-supplier"]';
+const openPeerFilter = async () => {
+ fireEvent.click(await screen.findByText('ui-rs.filter.supplier'));
+ await waitFor(() => expect(document.querySelector(peerInputSelector)).not.toBeNull());
+ const input = document.querySelector(peerInputSelector);
+ fireEvent.focus(input);
+ fireEvent.click(input);
+ return input;
+};
+
+// Visible option rows within the peer menu (scoped to its listbox so the qindex
+//