UNPKG

@plone/volto

Version:
479 lines (425 loc) 14.5 kB
import React from 'react'; import { useSelector } from 'react-redux'; import qs from 'query-string'; import { useLocation, useHistory } from 'react-router-dom'; import { resolveExtension } from '@plone/volto/helpers/Extensions/withBlockExtensions'; import config from '@plone/volto/registry'; import { usePrevious } from '@plone/volto/helpers/Utils/usePrevious'; import isEqual from 'lodash/isEqual'; function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; } const SEARCH_ENDPOINT_FIELDS = [ 'SearchableText', 'b_size', 'limit', 'sort_on', 'sort_order', 'depth', ]; const PAQO = 'plone.app.querystring.operation'; /** * Based on URL state, gets an initial internal state for the search * * @function getInitialState * */ function getInitialState( data, facets, urlSearchText, id, sortOnParam, sortOrderParam, ) { const { types: facetWidgetTypes } = config.blocks.blocksConfig.search.extensions.facetWidgets; const facetSettings = data?.facets || []; return { query: [ ...(data.query?.query || []), ...(facetSettings || []) .map((facet) => { if (!facet?.field) return null; const { valueToQuery } = resolveExtension( 'type', facetWidgetTypes, facet, ); const name = facet.field.value; const value = facets[name]; return valueToQuery({ value, facet }); }) .filter((f) => !!f), ...(urlSearchText ? [ { i: 'SearchableText', o: 'plone.app.querystring.operation.string.contains', v: urlSearchText, }, ] : []), ], sort_on: sortOnParam || data.query?.sort_on, sort_order: sortOrderParam || data.query?.sort_order, b_size: data.query?.b_size, limit: data.query?.limit, depth: data.query?.depth, block: id, }; } /** * "Normalizes" the search state to something that's serializable * (for querying) and used to compute data for the ListingBody * * @function normalizeState * */ function normalizeState({ query, // base query facets, // facet values id, // block id searchText, // SearchableText sortOn, sortOrder, facetSettings, // data.facets extracted from block data }) { const { types: facetWidgetTypes } = config.blocks.blocksConfig.search.extensions.facetWidgets; // Here, we are removing the QueryString of the Listing ones, which is present in the Facet // because we already initialize the facet with those values. const configuredFacets = facetSettings ? facetSettings.map((facet) => facet?.field?.value) : []; let copyOfQuery = query.query ? [...query.query] : []; const queryWithoutFacet = copyOfQuery.filter((query) => { return !configuredFacets.includes(query.i); }); const params = { query: [ ...(queryWithoutFacet || []), ...(facetSettings || []).map((facet) => { if (!facet?.field) return null; const { valueToQuery } = resolveExtension( 'type', facetWidgetTypes, facet, ); const name = facet.field.value; const value = facets[name]; return valueToQuery({ value, facet }); }), ].filter((o) => !!o), sort_on: sortOn || query.sort_on, sort_order: sortOrder || query.sort_order, b_size: query.b_size, limit: query.limit, depth: query.depth, block: id, }; // Note Ideally the searchtext functionality should be restructured as being just // another facet. But right now it's the same. This means that if a searchText // is provided, it will override the SearchableText facet. // If there is no searchText, the SearchableText in the query remains in effect. // TODO eventually the searchText should be a distinct facet from SearchableText, and // the two conditions could be combined, in comparison to the current state, when // one overrides the other. if (searchText) { params.query = params.query.reduce( // Remove SearchableText from query (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), [], ); params.query.push({ i: 'SearchableText', o: 'plone.app.querystring.operation.string.contains', v: searchText, }); } return params; } const getSearchFields = (searchData) => { return Object.assign( {}, ...SEARCH_ENDPOINT_FIELDS.map((k) => { return searchData[k] ? { [k]: searchData[k] } : {}; }), searchData.query ? { query: serializeQuery(searchData['query']) } : {}, ); }; /** * A hook that will mirror the search block state to a hash location */ const useHashState = () => { const location = useLocation(); const history = useHistory(); /** * Required to maintain parameter compatibility. With this we will maintain support for receiving hash (#) and search (?) type parameters. */ const oldState = React.useMemo(() => { return { ...qs.parse(location.search), ...qs.parse(location.hash), }; }, [location.hash, location.search]); // This creates a shallow copy. Why is this needed? const current = Object.assign( {}, ...Array.from(Object.keys(oldState)).map((k) => ({ [k]: oldState[k] })), ); const setSearchData = React.useCallback( (searchData) => { const newParams = qs.parse(location.search); let changed = false; Object.keys(searchData) .sort() .forEach((k) => { if (searchData[k]) { newParams[k] = searchData[k]; if (oldState[k] !== searchData[k]) { changed = true; } } }); if (changed) { history.push({ search: qs.stringify(newParams), }); } }, [history, oldState, location.search], ); return [current, setSearchData]; }; /** * A hook to make it possible to switch disable mirroring the search block * state to the window location. When using the internal state we "start from * scratch", as it's intended to be used in the edit page. */ const useSearchBlockState = (uniqueId, isEditMode) => { const [hashState, setHashState] = useHashState(); const [internalState, setInternalState] = React.useState({}); return isEditMode ? [internalState, setInternalState] : [hashState, setHashState]; }; // Simple compress/decompress the state in URL by replacing the lengthy string const deserializeQuery = (q) => { return JSON.parse(q)?.map((kvp) => ({ ...kvp, o: kvp.o.replace(/^paqo/, PAQO), })); }; const serializeQuery = (q) => { return JSON.stringify( q?.map((kvp) => ({ ...kvp, o: kvp.o.replace(PAQO, 'paqo') })), ); }; const withSearch = (options) => (WrappedComponent) => { const { inputDelay = 1000 } = options || {}; function WithSearch(props) { const { data, id, editable = false } = props; const [locationSearchData, setLocationSearchData] = useSearchBlockState( id, editable, ); // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 // eslint-disable-next-line react-hooks/exhaustive-deps const urlQuery = locationSearchData.query ? deserializeQuery(locationSearchData.query) : []; const urlSearchText = locationSearchData.SearchableText || urlQuery.find(({ i }) => i === 'SearchableText')?.v || ''; // TODO: refactor, should use only useLocationStateManager()!!! const [searchText, setSearchText] = React.useState(urlSearchText); // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 // eslint-disable-next-line react-hooks/exhaustive-deps const configuredFacets = data.facets?.map((facet) => facet?.field?.value) || []; // Here we are getting the initial value of the facet if Listing Query contains the same criteria as // facet. const queryData = data?.query?.query ? deserializeQuery(JSON.stringify(data?.query?.query)) : []; let intializeFacetWithQueryValue = []; for (let value of configuredFacets) { const queryString = queryData.find((item) => item.i === value); if (queryString) { intializeFacetWithQueryValue = [ ...intializeFacetWithQueryValue, { [queryString.i]: queryString.v }, ]; } } const multiFacets = data.facets ?.filter((facet) => facet?.multiple) .map((facet) => facet?.field?.value); const [facets, setFacets] = React.useState( Object.assign( {}, ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets ...intializeFacetWithQueryValue, // support for simple filters like ?Subject=something // TODO: since the move to hash params this is no longer working. // We'd have to treat the location.search and manage it just like the // hash, to support it. We can read it, but we'd have to reset it as // well, so at that point what's the difference to the hash? ...configuredFacets.map((f) => locationSearchData[f] ? { [f]: multiFacets.indexOf(f) > -1 ? [locationSearchData[f]] : locationSearchData[f], } : {}, ), ), ); const previousUrlQuery = usePrevious(urlQuery); // During first render the previousUrlQuery is undefined and urlQuery // is empty so it resetting the facet when you are navigating but during reload we have urlQuery and we need // to set the facet at first render. const preventOverrideOfFacetState = previousUrlQuery === undefined && urlQuery.length === 0; React.useEffect(() => { if ( !isEqual(urlQuery, previousUrlQuery) && !preventOverrideOfFacetState ) { setFacets( Object.assign( {}, ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets // support for simple filters like ?Subject=something // TODO: since the move to hash params this is no longer working. // We'd have to treat the location.search and manage it just like the // hash, to support it. We can read it, but we'd have to reset it as // well, so at that point what's the difference to the hash? ...configuredFacets.map((f) => locationSearchData[f] ? { [f]: multiFacets.indexOf(f) > -1 ? [locationSearchData[f]] : locationSearchData[f], } : {}, ), ), ); } }, [ urlQuery, configuredFacets, locationSearchData, multiFacets, previousUrlQuery, preventOverrideOfFacetState, ]); const [sortOn, setSortOn] = React.useState(data?.query?.sort_on); const [sortOrder, setSortOrder] = React.useState(data?.query?.sort_order); const [searchData, setSearchData] = React.useState( getInitialState(data, facets, urlSearchText, id), ); const deepFacets = JSON.stringify(facets); const deepData = JSON.stringify(data); React.useEffect(() => { setSearchData( getInitialState( JSON.parse(deepData), JSON.parse(deepFacets), urlSearchText, id, sortOn, sortOrder, ), ); }, [deepData, deepFacets, urlSearchText, id, sortOn, sortOrder]); const timeoutRef = React.useRef(); const facetSettings = data?.facets; const deepQuery = JSON.stringify(data.query); const onTriggerSearch = React.useCallback( ( toSearchText = undefined, toSearchFacets = undefined, toSortOn = undefined, toSortOrder = undefined, ) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout( () => { const newSearchData = normalizeState({ id, query: data.query || {}, facets: toSearchFacets || facets, searchText: toSearchText ? toSearchText.trim() : '', sortOn: toSortOn || undefined, sortOrder: toSortOrder || sortOrder, facetSettings, }); if (toSearchFacets) setFacets(toSearchFacets); if (toSortOn) setSortOn(toSortOn || undefined); if (toSortOrder) setSortOrder(toSortOrder); setSearchData(newSearchData); setLocationSearchData(getSearchFields(newSearchData)); }, toSearchFacets ? inputDelay / 3 : inputDelay, ); }, // eslint-disable-next-line react-hooks/exhaustive-deps [ // Use deep comparison of data.query deepQuery, facets, id, setLocationSearchData, searchText, sortOn, sortOrder, facetSettings, ], ); const removeSearchQuery = () => { let newSearchData = { ...searchData }; newSearchData.query = searchData.query.reduce( // Remove SearchableText from query (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), [], ); setSearchData(newSearchData); setLocationSearchData(getSearchFields(newSearchData)); }; const querystringResults = useSelector( (state) => state.querystringsearch.subrequests, ); const totalItems = querystringResults[id]?.total || querystringResults[id]?.items?.length; return ( <WrappedComponent {...props} searchData={searchData} facets={facets} setFacets={setFacets} setSortOn={setSortOn} setSortOrder={setSortOrder} sortOn={sortOn} sortOrder={sortOrder} searchedText={urlSearchText} searchText={searchText} removeSearchQuery={removeSearchQuery} setSearchText={setSearchText} onTriggerSearch={onTriggerSearch} totalItems={totalItems} /> ); } WithSearch.displayName = `WithSearch(${getDisplayName(WrappedComponent)})`; return WithSearch; }; export default withSearch;