Skip to content
Merged
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
17 changes: 17 additions & 0 deletions ui-rs/src/components/PatronRequests/Filters.css
Original file line number Diff line number Diff line change
@@ -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; }
69 changes: 68 additions & 1 deletion ui-rs/src/components/PatronRequests/Filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -16,14 +18,47 @@ 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 <><mark className={css.mark}>{text.slice(0, trimmed.length)}</mark>{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 (
<span className={css.peerOption}>
<span>
{option.name && <>{mark(option.name)} </>}
<span className={option.name ? css.peerSymbol : undefined}>({mark(option.symbol)})</span>
</span>
{option.count != null && <span>{option.count}</span>}
</span>
);
};

const Filters = ({ activeFilters, filterHandlers, options, peerFacet = {} }) => {
const { name: peerFilterName, ready: peerFacetReady, loading: peerFacetLoading, onType: onPeerFilterType } = peerFacet;

const onChangeHandler = (group) => {
filterHandlers.state({
...activeFilters,
[group.name]: group.values
});
};

// 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 (
<>
<CheckboxFilter
Expand Down Expand Up @@ -108,6 +143,38 @@ const Filters = ({ activeFilters, filterHandlers, options }) => {
onChange={onChangeHandler}
/>
</Accordion>
{peerFilterName && (
<Accordion
label={<FormattedMessage id={`ui-rs.filter.${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 ? (
<MultiSelectionFilter
ariaLabelledBy={`accordion-toggle-button-${peerFilterName}`}
name={peerFilterName}
dataOptions={options[peerFilterName]}
selectedValues={activeFilters?.[peerFilterName]}
onChange={onChangeHandler}
formatter={peerOptionFormatter}
valueFormatter={({ option }) => option.label}
showLoading={peerFacetLoading}
asyncFiltering
filter={asyncPeerFilter}
/>
) : (
<Loading />
)}
</Accordion>
)}
</AccordionSet>
<Accordion
closedByDefault
Expand Down
9 changes: 8 additions & 1 deletion ui-rs/src/components/PatronRequests/PatronRequests.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ const appDetails = {
},
};

const PatronRequests = ({ requestsQuery, perPage, filterOptions, children }) => {
const PatronRequests = ({
requestsQuery,
perPage,
filterOptions,
peerFacet,
children,
}) => {
const appName = useContext(AppNameContext);
const history = useHistory();
const intl = useIntl();
Expand Down Expand Up @@ -151,6 +157,7 @@ const PatronRequests = ({ requestsQuery, perPage, filterOptions, children }) =>
activeFilters={activeFilters.state}
filterHandlers={getFilterHandlers()}
options={filterOptions}
peerFacet={peerFacet}
/>
</Pane>
{requestsQuery.isLoading && <LoadingPane />}
Expand Down
88 changes: 86 additions & 2 deletions ui-rs/src/routes/PatronRequestsRoute.js
Original file line number Diff line number Diff line change
@@ -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));
};
Comment thread
skomorokh marked this conversation as resolved.

const PatronRequestsRoute = ({ appName, children }) => {
const intl = useIntl();
const ky = useOkapiKy();
Expand All @@ -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);
Comment thread
skomorokh marked this conversation as resolved.

// 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],
Expand All @@ -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' }],
Expand All @@ -53,13 +130,20 @@ const PatronRequestsRoute = ({ appName, children }) => {
state: (stateModelQuery.data?.states || [])
.filter(s => s.side === stateSide)
.map(s => ({ label: s.display, value: s.name })),
[peerFilterName]: peerOptions,
};

return (
<PatronRequests
requestsQuery={prQuery}
perPage={PER_PAGE}
filterOptions={filterOptions}
peerFacet={{
name: peerFilterName,
ready: peerFacetQuery.isSuccess,
loading: peerFacetQuery.isFetching,
onType: setPeerTerm,
}}
>
{children}
</PatronRequests>
Expand Down
Loading
Loading