diff --git a/src/components/PatronRequests/BackendMultiSelectionFilter.js b/src/components/PatronRequests/BackendMultiSelectionFilter.js new file mode 100644 index 00000000..d1402fca --- /dev/null +++ b/src/components/PatronRequests/BackendMultiSelectionFilter.js @@ -0,0 +1,106 @@ +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { MultiSelection } from '@folio/stripes/components'; +import { useOkapiQuery } from '@projectreshare/stripes-reshare'; + +const MIN_SEARCH_LENGTH = 2; + +const BackendMultiSelectionFilter = ({ + name, + selectedValues = [], + onChange, + endpoint, + searchParamsTemplate, + resultsPath = 'results', + labelKey, + valueKey, + placeholder, + disabled, + ...otherProps +}) => { + const intl = useIntl(); + const [filterValue, setFilterValue] = useState(''); + + // Cache all items we've seen in order to display selected items + const itemCache = useRef(new Map()); + + // Note: MultiSelection already debounces the filter function call at 300ms + const shouldFetch = filterValue.length >= MIN_SEARCH_LENGTH; + const searchParams = shouldFetch + ? searchParamsTemplate.replace('{searchTerm}', filterValue) + : ''; + + const query = useOkapiQuery(endpoint, { + searchParams, + enabled: shouldFetch, + staleTime: 5 * 60 * 1000, + cacheTime: 2 * 60 * 60 * 1000, + }); + + // Map response data to dataOptions format + const data = query.data?.[resultsPath] || []; + const searchResults = data.map(item => ({ + label: item[labelKey], + value: item[valueKey] + })); + + // Update cache with new search results + useEffect(() => { + searchResults.forEach(item => { + itemCache.current.set(item.value, item); + }); + }, [searchResults]); + + // Build dataOptions: include search results + any selected items from cache + const dataOptions = [...searchResults]; + selectedValues.forEach(val => { + if (!dataOptions.find(opt => opt.value === val) && itemCache.current.has(val)) { + dataOptions.push(itemCache.current.get(val)); + } + }); + + const onChangeHandler = (selectedDataOptions) => { + onChange({ + name, + values: selectedDataOptions.map(opt => opt.value) + }); + }; + + // Handle filter input changes - called by MultiSelection when asyncFiltering is true + const handleFilterChange = (value) => { + setFilterValue(value); + return { renderedItems: dataOptions, exactMatch: false }; + }; + + return ( + dataOptions.find(opt => opt.value === val)) + .filter(Boolean)} + onChange={onChangeHandler} + filter={handleFilterChange} + showLoading={shouldFetch && query.isLoading} + placeholder={placeholder || intl.formatMessage({ id: 'ui-rs.filter.typeToSearch' })} + disabled={disabled} + {...otherProps} + /> + ); +}; + +BackendMultiSelectionFilter.propTypes = { + name: PropTypes.string.isRequired, + selectedValues: PropTypes.arrayOf(PropTypes.string), + onChange: PropTypes.func.isRequired, + endpoint: PropTypes.string.isRequired, + searchParamsTemplate: PropTypes.string.isRequired, + resultsPath: PropTypes.string, + labelKey: PropTypes.string.isRequired, + valueKey: PropTypes.string.isRequired, + placeholder: PropTypes.string, + disabled: PropTypes.bool, +}; + +export default BackendMultiSelectionFilter; diff --git a/src/components/PatronRequests/Filters.js b/src/components/PatronRequests/Filters.js index 6b819fc5..b7ba7dc0 100644 --- a/src/components/PatronRequests/Filters.js +++ b/src/components/PatronRequests/Filters.js @@ -12,6 +12,7 @@ import { // import { DateFilter } from '@folio/stripes-erm-components'; import AppNameContext from '../../AppNameContext'; import DateFilter from './DateFilter'; +import BackendMultiSelectionFilter from './BackendMultiSelectionFilter'; // import css from './Filters.css'; @@ -77,12 +78,25 @@ const Filters = ({ activeFilters, filterHandlers, options, appDetails }) => { displayClearButton={activeFilters?.[institutionFilterId]?.length > 0} onClearFilter={() => filterHandlers.clearGroup(institutionFilterId)} > - + {options.institution ? ( + + ) : ( + + )} {appName === 'supply' && <> diff --git a/src/components/PatronRequests/PatronRequests.js b/src/components/PatronRequests/PatronRequests.js index 4e25d734..4dc0dcbf 100644 --- a/src/components/PatronRequests/PatronRequests.js +++ b/src/components/PatronRequests/PatronRequests.js @@ -70,6 +70,11 @@ const PatronRequests = ({ requestsQuery, queryGetter, querySetter, filterOptions const stripes = useStripes(); const [offset, setOffset] = useState(0); + // Reset pagination when filters/search changes + useEffect(() => { + setOffset(0); + }, [location.search]); + const requests = requestsQuery?.data?.pages?.[offset / perPage]?.results; const sparseRequests = (new Array(offset)).concat(requests); const totalCount = requestsQuery?.data?.pages?.[0]?.total; @@ -120,7 +125,6 @@ const PatronRequests = ({ requestsQuery, queryGetter, querySetter, filterOptions diff --git a/src/routes/PatronRequestsRoute.js b/src/routes/PatronRequestsRoute.js index 07ab3336..1d79fffe 100644 --- a/src/routes/PatronRequestsRoute.js +++ b/src/routes/PatronRequestsRoute.js @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useInfiniteQuery } from 'react-query'; import { useIntl } from 'react-intl'; +import { useHistory, useLocation } from 'react-router-dom'; +import queryString from 'query-string'; import { useOkapiKy } from '@folio/stripes/core'; import { generateKiwtQuery, useKiwtSASQuery } from '@k-int/stripes-kint-components'; -import { useOkapiQuery } from '@projectreshare/stripes-reshare'; +import { useOkapiQuery, useSetting } from '@projectreshare/stripes-reshare'; import PatronRequests from '../components/PatronRequests'; const PER_PAGE = 100; @@ -22,10 +24,44 @@ const SUPPLIER = 'supply'; const PatronRequestsRoute = ({ appName, children }) => { const intl = useIntl(); + const location = useLocation(); + const history = useHistory(); const { query, queryGetter, querySetter } = useKiwtSASQuery(); const ky = useOkapiKy(); const isSupplier = appName === SUPPLIER; + // Sync query state when location.search changes (e.g., reset filters link clicked) + useEffect(() => { + const parsedQuery = queryString.parse(location.search); + + // Ensure all standard query fields are present (set to empty string if missing) + // This is important because querySetter merges rather than replaces + const fullQuery = { + query: '', + filters: '', + sort: '', + ...parsedQuery + }; + + const currentQueryString = queryString.stringify(query); + const newQueryString = queryString.stringify(fullQuery); + + if (currentQueryString !== newQueryString) { + querySetter({ + nsValues: fullQuery, + location, + history, + state: { changeType: 'external-location' } // Prevents duplicate URL being pushed to history + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.search]); // Only depend on location.search (not query) to avoid infinite loop + + // Check if backend institution filter should be used (SLNP mode) + const stateModelSetting = useSetting('requester_returnables_state_model'); + const useBackendFilter = stateModelSetting.isSuccess && + stateModelSetting.value?.startsWith('SLNP'); + const SASQ_MAP = { searchKey: 'id,hrid,patronGivenName,patronSurname,patronIdentifier,title,author,issn,isbn,volumes.itemId,selectedItemBarcode', // Omitting the date and unread filter keys here causes it to include their value verbatim @@ -122,7 +158,8 @@ const PatronRequestsRoute = ({ appName, children }) => { perPage: '1000', stats: 'true', }, - staleTime: 2 * 60 * 60 * 1000 + staleTime: 2 * 60 * 60 * 1000, + enabled: !useBackendFilter // Only load static institutions when backend filter is disabled }), useOkapiQuery('rs/settings/appSettings', { searchParams: { @@ -140,17 +177,17 @@ const PatronRequestsRoute = ({ appName, children }) => { ]; let filterOptions; - if (filterQueries.every(x => x.isSuccess)) { - const [batches, lmsLocations, shelvingLocations, { results: institutions }, settings, refDataRequestServiceType] = filterQueries.map(x => x.data); + // Check if queries are ready (successful or disabled/idle) + const queriesReady = filterQueries.every(x => x.isSuccess || x.status === 'idle'); + + if (queriesReady) { + const [batches, lmsLocations, shelvingLocations, dirQueryData, settings, refDataRequestServiceType] = filterQueries.map(x => x.data); filterOptions = { batch: batches .sort(compareCreated) .map(x => ({ label: x.description, value: x.id, dateCreated: x.dateCreated })), hasLocalNote: [({ label: intl.formatMessage({ id: 'stripes-reshare.hasLocalNote' }), value: 'localNote ISNOTNULL' })], hasUnread: [({ label: intl.formatMessage({ id: 'ui-rs.unread' }), value: 'hasUnreadMessages=true' })], - institution: institutions - .map(x => ({ label: x.name, value: x.id })) - .sort(compareLabel), location: lmsLocations .map(x => ({ label: x.name, value: x.id })) .sort(compareLabel), @@ -162,6 +199,13 @@ const PatronRequestsRoute = ({ appName, children }) => { terminal: [({ label: intl.formatMessage({ id: 'ui-rs.hideComplete' }), value: 'false' })], serviceType: getRequestServiceTypes(refDataRequestServiceType) }; + + // Only add static institutions if NOT using backend filter + if (!useBackendFilter && dirQueryData?.results) { + filterOptions.institution = dirQueryData.results + .map(x => ({ label: x.name, value: x.id })) + .sort(compareLabel); + } } return ( @@ -172,7 +216,6 @@ const PatronRequestsRoute = ({ appName, children }) => { filterOptions={filterOptions} searchParams={generateKiwtQuery(SASQ_MAP, query)} perPage={PER_PAGE} - key={JSON.stringify(query)} > {children} diff --git a/translations/ui-rs/en.json b/translations/ui-rs/en.json index 9abe0a60..44c32f15 100644 --- a/translations/ui-rs/en.json +++ b/translations/ui-rs/en.json @@ -617,6 +617,7 @@ "filter.dateSubmitted": "Date created", "filter.dateNeeded": "Date needed", "filter.serviceType": "Service Type", + "filter.typeToSearch": "Type to search...", "hideComplete": "Hide completed", "unread": "Unread messages", "needsAttention": "Needs attention",