From db96cc4ef8690b457bb6743a7fee73c75a2b654f Mon Sep 17 00:00:00 2001 From: Jason Skomorowski Date: Mon, 27 Jul 2026 11:59:13 -0400 Subject: [PATCH] Peer facet ILLDEV-433 --- .../src/components/PatronRequests/Filters.css | 17 ++ .../src/components/PatronRequests/Filters.js | 69 +++++++- .../PatronRequests/PatronRequests.js | 9 +- ui-rs/src/routes/PatronRequestsRoute.js | 88 +++++++++- ui-rs/src/routes/PatronRequestsRoute.test.js | 163 +++++++++++++++++- ui-rs/src/test/okapiKyMock.js | 42 +++-- ui-rs/src/util/buildPatronRequestsCql.js | 48 +++++- ui-rs/src/util/buildPatronRequestsCql.test.js | 71 +++++++- 8 files changed, 483 insertions(+), 24 deletions(-) create mode 100644 ui-rs/src/components/PatronRequests/Filters.css 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 {location.search}; }; -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 +//