diff --git a/src/customizations/volto/components/manage/Blocks/Listing/withQuerystringResults.jsx b/src/customizations/volto/components/manage/Blocks/Listing/withQuerystringResults.jsx new file mode 100644 index 00000000..2d394c72 --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Listing/withQuerystringResults.jsx @@ -0,0 +1,161 @@ +import React, { useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import useDeepCompareEffect from 'use-deep-compare-effect'; + +import { getContent, getQueryStringResults } from '@plone/volto/actions'; +import { usePagination, getBaseUrl } from '@plone/volto/helpers'; + +import config from '@plone/volto/registry'; + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +export default function withQuerystringResults(WrappedComponent) { + function WithQuerystringResults(props) { + const { + data = {}, + id = data.block, + properties: content, + path, + variation, + } = props; + const { settings } = config; + const querystring = data.querystring || data; // For backwards compat with data saved before Blocks schema. Note, this is also how the Search block passes data to ListingBody + const subrequestID = content?.UID ? `${content?.UID}-${id}` : id; + const { b_size = settings.defaultPageSize } = querystring; // batchsize + + // save the path so it won't trigger dispatch on eager router location change + const [initialPath] = React.useState(getBaseUrl(path)); + + const copyFields = [ + 'limit', + 'query', + 'sort_on', + 'sort_order', + 'depth', + 'facets', + ]; + const { currentPage, setCurrentPage } = usePagination(id, 1); + const adaptedQuery = Object.assign( + variation?.fullobjects ? { fullobjects: 1 } : { metadata_fields: '_all' }, + { + b_size: b_size, + }, + ...copyFields.map((name) => + Object.keys(querystring).includes(name) + ? { [name]: querystring[name] } + : {}, + ), + ); + const adaptedQueryRef = useRef(adaptedQuery); + const currentPageRef = useRef(currentPage); + + const querystringResults = useSelector( + (state) => state.querystringsearch.subrequests, + ); + const dispatch = useDispatch(); + + const folderItems = content?.is_folderish ? content.items : []; + const hasQuery = querystring?.query?.length > 0; + const hasLoaded = hasQuery + ? querystringResults?.[subrequestID]?.loaded + : true; + + const listingItems = hasQuery + ? querystringResults?.[subrequestID]?.items || [] + : folderItems; + + const showAsFolderListing = !hasQuery && content?.items_total > b_size; + const showAsQueryListing = + hasQuery && querystringResults?.[subrequestID]?.total > b_size; + + const totalPages = showAsFolderListing + ? Math.ceil(content.items_total / b_size) + : showAsQueryListing + ? Math.ceil(querystringResults[subrequestID].total / b_size) + : 0; + + const prevBatch = showAsFolderListing + ? content.batching?.prev + : showAsQueryListing + ? querystringResults[subrequestID].batching?.prev + : null; + const nextBatch = showAsFolderListing + ? content.batching?.next + : showAsQueryListing + ? querystringResults[subrequestID].batching?.next + : null; + + const isImageGallery = + (!data.variation && data.template === 'imageGallery') || + data.variation === 'imageGallery'; + + useDeepCompareEffect(() => { + if (hasQuery) { + dispatch( + getQueryStringResults( + initialPath, + adaptedQuery, + subrequestID, + currentPage, + ), + ); + } else if (isImageGallery && !hasQuery) { + // when used as image gallery, it doesn't need a query to list children + dispatch( + getQueryStringResults( + initialPath, + { + ...adaptedQuery, + b_size: 10000000000, + query: [ + { + i: 'path', + o: 'plone.app.querystring.operation.string.relativePath', + v: '', + }, + ], + }, + subrequestID, + ), + ); + } else { + dispatch(getContent(initialPath, null, null, currentPage)); + } + adaptedQueryRef.current = adaptedQuery; + currentPageRef.current = currentPage; + }, [ + subrequestID, + isImageGallery, + adaptedQuery, + hasQuery, + initialPath, + dispatch, + currentPage, + ]); + + return ( + setCurrentPage(activePage)} + total={querystringResults?.[subrequestID]?.total} + batch_size={b_size} + currentPage={currentPage} + totalPages={totalPages} + prevBatch={prevBatch} + nextBatch={nextBatch} + listingItems={listingItems} + hasLoaded={hasLoaded} + isFolderContentsListing={showAsFolderListing} + /> + ); + } + + WithQuerystringResults.displayName = `WithQuerystringResults(${getDisplayName( + WrappedComponent, + )})`; + + return hoistNonReactStatics(WithQuerystringResults, WrappedComponent); +} diff --git a/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx b/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx new file mode 100644 index 00000000..adf4d1ce --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx @@ -0,0 +1,100 @@ +import React, { useEffect } from 'react'; +import { defineMessages } from 'react-intl'; +import { compose } from 'redux'; + +import { SidebarPortal, BlockDataForm } from '@plone/volto/components'; +import { addExtensionFieldToSchema } from '@plone/volto/helpers/Extensions/withBlockSchemaEnhancer'; +import { getBaseUrl } from '@plone/volto/helpers'; +import config from '@plone/volto/registry'; + +import { SearchBlockViewComponent } from './SearchBlockView'; +import Schema from '@plone/volto/components/manage/Blocks/Search/schema'; +import { withSearch, withQueryString, withFacetsCount } from './hocs'; +import { cloneDeep } from 'lodash'; + +const messages = defineMessages({ + template: { + id: 'Results template', + defaultMessage: 'Results template', + }, +}); + +const SearchBlockEdit = (props) => { + const { + block, + onChangeBlock, + data, + selected, + intl, + navRoot, + contentType, + onTriggerSearch, + querystring = {}, + } = props; + const { sortable_indexes = {} } = querystring; + + let schema = Schema({ data, intl }); + + schema = addExtensionFieldToSchema({ + schema, + name: 'listingBodyTemplate', + items: config.blocks.blocksConfig.listing.variations, + intl, + title: { id: intl.formatMessage(messages.template) }, + }); + const listingVariations = config.blocks.blocksConfig?.listing?.variations; + let activeItem = listingVariations.find( + (item) => item.id === data.listingBodyTemplate, + ); + const listingSchemaEnhancer = activeItem?.schemaEnhancer; + if (listingSchemaEnhancer) + schema = listingSchemaEnhancer({ + schema: cloneDeep(schema), + data, + intl, + }); + schema.properties.sortOnOptions.items = { + choices: Object.keys(sortable_indexes).map((k) => [ + k, + sortable_indexes[k].title, + ]), + }; + + const { query = {} } = data || {}; + // We don't need deep compare here, as this is just json serializable data. + const deepQuery = JSON.stringify(query); + useEffect(() => { + onTriggerSearch(); + }, [deepQuery, onTriggerSearch]); + + return ( + <> + + + { + onChangeBlock(block, { + ...data, + [id]: value, + }); + }} + onChangeBlock={onChangeBlock} + formData={data} + navRoot={navRoot} + contentType={contentType} + /> + + + ); +}; + +export default compose( + withQueryString, + withFacetsCount, + withSearch(), +)(SearchBlockEdit); diff --git a/src/customizations/volto/components/manage/Blocks/Search/SearchBlockView.jsx b/src/customizations/volto/components/manage/Blocks/Search/SearchBlockView.jsx new file mode 100644 index 00000000..0c2c4bb9 --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/SearchBlockView.jsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import ListingBody from '@plone/volto/components/manage/Blocks/Listing/ListingBody'; +import { withBlockExtensions } from '@plone/volto/helpers'; + +import config from '@plone/volto/registry'; + +import { withSearch, withQueryString, withFacetsCount } from './hocs'; +import { compose } from 'redux'; +import { useSelector } from 'react-redux'; +import { isEqual, isFunction } from 'lodash'; +import cx from 'classnames'; + +const getListingBodyVariation = (data) => { + const { variations } = config.blocks.blocksConfig.listing; + + let variation = data.listingBodyTemplate + ? variations.find(({ id }) => id === data.listingBodyTemplate) + : variations.find(({ isDefault }) => isDefault); + + if (!variation) variation = variations[0]; + + return variation; +}; + +const isfunc = (obj) => isFunction(obj) || typeof obj === 'function'; + +const _filtered = (obj) => + Object.assign( + {}, + ...Object.keys(obj).map((k) => { + const reject = k !== 'properties' && !isfunc(obj[k]); + return reject ? { [k]: obj[k] } : {}; + }), + ); + +const blockPropsAreChanged = (prevProps, nextProps) => { + const prev = _filtered(prevProps); + const next = _filtered(nextProps); + + return isEqual(prev, next); +}; + +const applyDefaults = (data, root) => { + const defaultQuery = [ + { + i: 'path', + o: 'plone.app.querystring.operation.string.absolutePath', + v: root || '/', + }, + ]; + return { + ...data, + sort_on: data?.sort_on || 'effective', + sort_order: data?.sort_order || 'descending', + query: data?.query?.length ? data.query : defaultQuery, + }; +}; + +const SearchBlockView = (props) => { + const { id, data, searchData, mode = 'view', variation, className } = props; + + const Layout = variation.view; + + const dataListingBodyVariation = getListingBodyVariation(data).id; + const [selectedView, setSelectedView] = React.useState( + dataListingBodyVariation, + ); + + // in the block edit you can change the used listing block variation, + // but it's cached here in the state. So we reset it. + React.useEffect(() => { + if (mode !== 'view') { + setSelectedView(dataListingBodyVariation); + } + }, [dataListingBodyVariation, mode]); + + const root = useSelector((state) => state.breadcrumbs.root); + const listingBodyData = applyDefaults(searchData, root); + + const { variations } = config.blocks.blocksConfig.listing; + const listingBodyVariation = variations.find(({ id }) => id === selectedView); + + return ( +
+ + + +
+ ); +}; + +export const SearchBlockViewComponent = compose( + withBlockExtensions, + (Component) => React.memo(Component, blockPropsAreChanged), +)(SearchBlockView); + +export default withSearch()( + withQueryString(withFacetsCount(SearchBlockViewComponent)), +); diff --git a/src/customizations/volto/components/manage/Blocks/Search/components/CheckboxFacet.jsx b/src/customizations/volto/components/manage/Blocks/Search/components/CheckboxFacet.jsx new file mode 100644 index 00000000..7162868f --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/components/CheckboxFacet.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Checkbox, Header } from 'semantic-ui-react'; +import { + selectFacetSchemaEnhancer, + selectFacetStateToValue, + selectFacetValueToQuery, +} from '@plone/volto/components/manage/Blocks/Search/components/base'; + +/** + * A facet that uses radio/checkboxes to provide an explicit list of values for + * filtering + */ +const CheckboxFacet = (props) => { + const { + facet, + facetCount, + choices, + isMulti, + onChange, + value, + isEditMode, + isFacetCountEnabled, + } = props; + const facetValue = value; + + return ( +
+
{facet.title ?? facet?.field?.label}
+
+ {choices.map(({ label, value }, i) => { + const count = facetCount?.data?.[value] || 0; + + return ( +
+ {isFacetCountEnabled === true ? ( + f.value === value) + : facetValue && facetValue.value === value + } + onChange={(e, { checked }) => + onChange( + facet.field.value, + isMulti + ? [ + ...facetValue + .filter((f) => f.value !== value) + .map((f) => f.value), + ...(checked ? [value] : []), + ] + : checked + ? value + : null, + ) + } + /> + ) : ( + f.value === value) + : facetValue && facetValue.value === value + } + onChange={(e, { checked }) => + onChange( + facet.field.value, + isMulti + ? [ + ...facetValue + .filter((f) => f.value !== value) + .map((f) => f.value), + ...(checked ? [value] : []), + ] + : checked + ? value + : null, + ) + } + /> + )} +
+ ); + })} +
+
+ ); +}; + +CheckboxFacet.schemaEnhancer = selectFacetSchemaEnhancer; +CheckboxFacet.stateToValue = selectFacetStateToValue; +CheckboxFacet.valueToQuery = selectFacetValueToQuery; + +export default CheckboxFacet; diff --git a/src/customizations/volto/components/manage/Blocks/Search/components/Facets.jsx b/src/customizations/volto/components/manage/Blocks/Search/components/Facets.jsx new file mode 100644 index 00000000..f42ee7ac --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/components/Facets.jsx @@ -0,0 +1,170 @@ +import React, { useState, useMemo } from 'react'; +import { Button, Grid } from 'semantic-ui-react'; +import { resolveExtension } from '@plone/volto/helpers/Extensions/withBlockExtensions'; +import config from '@plone/volto/registry'; +import { hasNonValueOperation, hasDateOperation } from '../utils'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + moreFilters: { id: 'More filters', defaultMessage: 'More filters' }, + lessFilters: { id: 'Less filters', defaultMessage: 'Less filters' }, + showFilters: { id: 'Show filters', defaultMessage: 'Show filters' }, + hideFilters: { id: 'Hide filters', defaultMessage: 'Hide filters' }, +}); + +const defaultShowFacet = (index) => { + const { values } = index; + return index + ? hasNonValueOperation(index.operations || []) || + hasDateOperation(index.operations || []) + ? true + : values && Object.keys(values).length > 0 + : values && Object.keys(values).length > 0; +}; + +const Facets = (props) => { + const [hidden, setHidden] = useState(true); + const { + querystring, + data = {}, + facets, + setFacets, + facetWrapper, + isEditMode, + } = props; + const { search } = config.blocks.blocksConfig; + + const advancedFilters = useMemo(() => { + let count = 0; + for (let facetSettings of data.facets || []) { + if (facetSettings.advanced) { + count++; + } + } + + if (count === data.facets?.length) { + return 2; + } + if (count) { + return 1; + } + return 0; + }, [data.facets]); + + const FacetWrapper = facetWrapper; + const query_to_values = Object.assign( + {}, + ...(data?.query?.query?.map(({ i, v }) => ({ [i]: v })) || []), + ); + const intl = useIntl(); + + return ( + <> + {data?.facets + ?.filter((facetSettings) => !facetSettings.hidden) + .map((facetSettings) => { + const field = facetSettings?.field?.value; + const isAdvanced = facetSettings?.advanced; + const index = querystring.indexes[field] || {}; + const { values = {} } = index; + const isFacetCountEnabled = props.facetsCount ? true : false; + const facetCount = + props.facetsCount?.[facetSettings?.field?.value] || 0; + + let choices = Object.keys(values) + .map((name) => ({ + value: name, + label: values[name].title, + })) + // filter the available values based on the allowed values in the + // base query + .filter(({ value }) => + query_to_values[field] + ? query_to_values[field].includes(value) + : true, + ); + + choices = choices.sort((a, b) => + a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }), + ); + + const isMulti = facetSettings.multiple; + const selectedValue = facets[facetSettings?.field?.value]; + const visible = (isAdvanced && !hidden) || !isAdvanced; + + // TODO :handle changing the type of facet (multi/nonmulti) + + const { + view: FacetWidget, + stateToValue, + showFacet = defaultShowFacet, + } = resolveExtension( + 'type', + search.extensions.facetWidgets.types, + facetSettings, + ); + + let value = stateToValue({ facetSettings, index, selectedValue }); + + const { + rewriteOptions = (name, options) => options, + } = search.extensions.facetWidgets; + + return FacetWrapper && (isEditMode || showFacet(index)) ? ( + + { + if (isFacetCountEnabled === false) return true; + return Object.keys(facetCount?.data || {}).find( + (key) => key === value, + ); + })} + isMulti={isMulti} + value={value} + isEditMode={isEditMode} + onChange={(id, value) => { + !isEditMode && setFacets({ ...facets, [id]: value }); + }} + /> + + ) : ( + '' + ); + })} + {advancedFilters > 0 && ( + + + + )} + + ); +}; + +export default Facets; diff --git a/src/customizations/volto/components/manage/Blocks/Search/components/SelectFacet.jsx b/src/customizations/volto/components/manage/Blocks/Search/components/SelectFacet.jsx new file mode 100644 index 00000000..4942edb1 --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/components/SelectFacet.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; +import { + Option, + DropdownIndicator, + MultiValueContainer, +} from '@plone/volto/components/manage/Widgets/SelectStyling'; +import { + selectTheme, + customSelectStyles, +} from '@plone/volto/components/manage/Blocks/Search/components/SelectStyling'; +import { + selectFacetSchemaEnhancer, + selectFacetStateToValue, + selectFacetValueToQuery, +} from '@plone/volto/components/manage/Blocks/Search/components/base'; + +const SelectFacet = (props) => { + const { + facet, + facetCount, + choices, + reactSelect, + isMulti, + onChange, + value, + isEditMode, + isFacetCountEnabled, + } = props; + const Select = reactSelect.default; + const v = Array.isArray(value) && value.length === 0 ? null : value; + if (isFacetCountEnabled) + return ( + { + if (data) { + onChange( + facet.field.value, + isMulti ? data.map(({ value }) => value) : data.value, + ); + } else { + // data has been removed + onChange(facet.field.value, isMulti ? [] : ''); + } + }} + isMulti={facet.multiple} + isClearable + value={v} + /> + ); +}; + +SelectFacet.schemaEnhancer = selectFacetSchemaEnhancer; +SelectFacet.stateToValue = selectFacetStateToValue; +SelectFacet.valueToQuery = selectFacetValueToQuery; + +export default injectLazyLibs('reactSelect')(SelectFacet); diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/index.js b/src/customizations/volto/components/manage/Blocks/Search/hocs/index.js new file mode 100644 index 00000000..2b1fcbc5 --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/hocs/index.js @@ -0,0 +1,3 @@ +export { default as withFacetsCount } from './withFacetsCount'; +export { default as withQueryString } from './withQueryString'; +export { default as withSearch } from './withSearch'; diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/withFacetsCount.jsx b/src/customizations/volto/components/manage/Blocks/Search/hocs/withFacetsCount.jsx new file mode 100644 index 00000000..196b0823 --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/hocs/withFacetsCount.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +/** + * A HOC that injects querystring metadata information from the backend. + * + */ +export default function withFacetsCount(WrappedComponent) { + function WithFacetsCount(props) { + const { id } = props; + + const facetsCount = useSelector((state) => { + return state.querystringsearch.subrequests[id]?.facets_count; + }); + + return ; + } + WithFacetsCount.displayName = `WithFacetsCount(${getDisplayName( + WrappedComponent, + )})`; + return WithFacetsCount; +} diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/withQueryString.jsx b/src/customizations/volto/components/manage/Blocks/Search/hocs/withQueryString.jsx new file mode 100644 index 00000000..98c4b12f --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/hocs/withQueryString.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getQuerystring } from '@plone/volto/actions'; + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +/** + * A HOC that injects querystring metadata information from the backend. + * + */ +export default function withQueryString(WrappedComponent) { + function WithQueryString(props) { + const dispatch = useDispatch(); + + const qs = useSelector((state) => state.querystring); + const indexes = qs?.indexes || {}; + + React.useEffect(() => { + if (Object.keys(indexes).length === 0) { + dispatch(getQuerystring()); + } + }, [dispatch, indexes]); + + return ; + } + WithQueryString.displayName = `WithQueryString(${getDisplayName( + WrappedComponent, + )})`; + return WithQueryString; +} diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx b/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx new file mode 100644 index 00000000..2f98fb8d --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx @@ -0,0 +1,484 @@ +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'; +import { isEqual } from 'lodash'; + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +const SEARCH_ENDPOINT_FIELDS = [ + 'SearchableText', + 'b_size', + 'limit', + 'sort_on', + 'sort_order', + 'facets', +]; + +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.map((q) => { + return { + ...q, + mandatory: true, + }; + }) || []), + ...(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, + facets: (data?.facets || []).map((facet) => facet?.field?.value), + 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), + facets: configuredFacets, + sort_on: sortOn || query.sort_on, + sort_order: sortOrder || query.sort_order, + b_size: query.b_size, + limit: query.limit, + 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 ressetting 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 || sortOn, + sortOrder: toSortOrder || sortOrder, + facetSettings, + }); + if (toSearchFacets) setFacets(toSearchFacets); + if (toSortOn) setSortOn(toSortOn); + 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 ( + + ); + } + WithSearch.displayName = `WithSearch(${getDisplayName(WrappedComponent)})`; + + return WithSearch; +}; + +export default withSearch; diff --git a/src/customizations/volto/components/manage/Blocks/Search/layout/LeftColumnFacets.jsx b/src/customizations/volto/components/manage/Blocks/Search/layout/LeftColumnFacets.jsx new file mode 100644 index 00000000..3649ec1f --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/layout/LeftColumnFacets.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { + SearchInput, + SearchDetails, + Facets, + FilterList, + SortOn, + ViewSwitcher, +} from '@plone/volto/components/manage/Blocks/Search/components'; +import { Grid, Segment } from 'semantic-ui-react'; +import { Button } from 'semantic-ui-react'; +import { flushSync } from 'react-dom'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + searchButtonText: { + id: 'Search', + defaultMessage: 'Search', + }, +}); + +const FacetWrapper = ({ children }) => ( + + {children} + +); + +const LeftColumnFacets = (props) => { + const { + children, + data, + totalItems, + facets, + facetsCount, + setFacets, + setSortOn, + setSortOrder, + sortOn, + sortOrder, + onTriggerSearch, + searchedText, // search text for previous search + searchText, // search text currently being entered (controlled input) + isEditMode, + querystring = {}, + // searchData, + // mode = 'view', + // variation, + } = props; + const { showSearchButton } = data; + const isLive = !showSearchButton; + const intl = useIntl(); + + return ( + + {data.headline && ( + + +

{data.headline}

+
+
+ )} + + + {data.facets?.length > 0 && ( + +
+ {data.facetsTitle &&

{data.facetsTitle}

} + { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + facetWrapper={FacetWrapper} + /> +
+
+ )} + + + {(Object.keys(data).includes('showSearchInput') + ? data.showSearchInput + : true) && ( +
+ + {data.showSearchButton && ( + + )} +
+ )} + + { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + /> + +
+ +
+ {data.showSortOn && ( + { + flushSync(() => { + setSortOn(sortOn); + onTriggerSearch(searchedText || '', facets, sortOn); + }); + }} + setSortOrder={(sortOrder) => { + flushSync(() => { + setSortOrder(sortOrder); + onTriggerSearch( + searchedText || '', + facets, + sortOn, + sortOrder, + ); + }); + }} + /> + )} + {data.availableViews && data.availableViews.length > 1 && ( + + )} +
+
+ {children} +
+
+
+ ); +}; + +export default LeftColumnFacets; diff --git a/src/customizations/volto/components/manage/Blocks/Search/layout/RightColumnFacets.jsx b/src/customizations/volto/components/manage/Blocks/Search/layout/RightColumnFacets.jsx new file mode 100644 index 00000000..182e28ca --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/layout/RightColumnFacets.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { + SearchInput, + SearchDetails, + Facets, + FilterList, + SortOn, + ViewSwitcher, +} from '@plone/volto/components/manage/Blocks/Search/components'; +import { Grid, Segment } from 'semantic-ui-react'; +import { Button } from 'semantic-ui-react'; +import { flushSync } from 'react-dom'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + searchButtonText: { + id: 'Search', + defaultMessage: 'Search', + }, +}); + +const FacetWrapper = ({ children }) => ( + + {children} + +); + +const RightColumnFacets = (props) => { + const { + children, + data, + totalItems, + facets, + facetsCount, + setFacets, + setSortOn, + setSortOrder, + sortOn, + sortOrder, + onTriggerSearch, + searchedText, // search text for previous search + searchText, // search text currently being entered (controlled input) + isEditMode, + querystring = {}, + // searchData, + // mode = 'view', + // variation, + } = props; + const { showSearchButton } = data; + const isLive = !showSearchButton; + const intl = useIntl(); + + return ( + + {data.headline && ( + + +

{data.headline}

+
+
+ )} + + + + {(Object.keys(data).includes('showSearchInput') + ? data.showSearchInput + : true) && ( +
+ + {data.showSearchButton && ( + + )} +
+ )} + + { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + /> + +
+ +
+ {data.showSortOn && ( + { + flushSync(() => { + setSortOn(sortOn); + onTriggerSearch(searchedText || '', facets, sortOn); + }); + }} + setSortOrder={(sortOrder) => { + flushSync(() => { + setSortOrder(sortOrder); + onTriggerSearch( + searchedText || '', + facets, + sortOn, + sortOrder, + ); + }); + }} + /> + )} + {data.availableViews && data.availableViews.length > 1 && ( + + )} +
+
+ {children} +
+ + {data.facets?.length > 0 && ( + +
+ {data.facetsTitle &&

{data.facetsTitle}

} + { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + facetWrapper={FacetWrapper} + /> +
+
+ )} +
+
+ ); +}; + +export default RightColumnFacets; diff --git a/src/customizations/volto/components/manage/Blocks/Search/layout/TopSideFacets.jsx b/src/customizations/volto/components/manage/Blocks/Search/layout/TopSideFacets.jsx new file mode 100644 index 00000000..b171000e --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/layout/TopSideFacets.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { flushSync } from 'react-dom'; +import { defineMessages, useIntl } from 'react-intl'; +import { Button, Grid } from 'semantic-ui-react'; + +import { + SearchInput, + SearchDetails, + Facets, + FilterList, + SortOn, + ViewSwitcher, +} from '@plone/volto/components/manage/Blocks/Search/components'; + +const messages = defineMessages({ + searchButtonText: { + id: 'Search', + defaultMessage: 'Search', + }, +}); + +const FacetWrapper = ({ children }) => ( + + {children} + +); + +const TopSideFacets = (props) => { + const { + children, + data, + totalItems, + facets, + facetsCount, + setFacets, + setSortOn, + setSortOrder, + sortOn, + sortOrder, + onTriggerSearch, + searchedText, // search text for previous search + searchText, // search text currently being entered (controlled input) + isEditMode, + querystring = {}, + // searchData, + // mode = 'view', + // variation, + } = props; + const { showSearchButton } = data; + const isLive = !showSearchButton; + const intl = useIntl(); + + return ( + + {data.headline && ( + + +

{data.headline}

+
+
+ )} + + + + {(Object.keys(data).includes('showSearchInput') + ? data.showSearchInput + : true) && ( +
+ + {data.showSearchButton && ( + + )} +
+ )} + +
+ { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + /> + + {data.showSortOn && ( + { + flushSync(() => { + setSortOn(sortOn); + onTriggerSearch(searchedText || '', facets, sortOn); + }); + }} + setSortOrder={(sortOrder) => { + flushSync(() => { + setSortOrder(sortOrder); + onTriggerSearch( + searchedText || '', + facets, + sortOn, + sortOrder, + ); + }); + }} + /> + )} + {data.availableViews && data.availableViews.length > 1 && ( + + )} +
+ {data.facets?.length > 0 && ( +
+ {data.facetsTitle &&

{data.facetsTitle}

} + + { + flushSync(() => { + setFacets(f); + onTriggerSearch(searchedText || '', f); + }); + }} + facetWrapper={FacetWrapper} + /> + +
+ )} + +
+
+ + + {children} + +
+ ); +}; + +export default TopSideFacets; diff --git a/src/customizations/volto/components/manage/Blocks/Search/utils.js b/src/customizations/volto/components/manage/Blocks/Search/utils.js new file mode 100644 index 00000000..bab89c4c --- /dev/null +++ b/src/customizations/volto/components/manage/Blocks/Search/utils.js @@ -0,0 +1,16 @@ +export const NONVALUE_OPERATIONS = new Set([ + 'plone.app.querystring.operation.boolean.isFalse', + 'plone.app.querystring.operation.boolean.isTrue', +]); + +export const DATE_OPERATIONS = new Set([ + 'plone.app.querystring.operation.date.between', +]); + +export const hasNonValueOperation = (ops) => { + return ops.filter((x) => NONVALUE_OPERATIONS.has(x)).length > 0; +}; + +export const hasDateOperation = (ops) => { + return ops.filter((x) => DATE_OPERATIONS.has(x)).length > 0; +}; diff --git a/src/customizations/volto/reducers/querystringsearch/querystringsearch.js b/src/customizations/volto/reducers/querystringsearch/querystringsearch.js new file mode 100644 index 00000000..729b7b47 --- /dev/null +++ b/src/customizations/volto/reducers/querystringsearch/querystringsearch.js @@ -0,0 +1,131 @@ +import { map, omit } from 'lodash'; +import { flattenToAppURL } from '@plone/volto/helpers'; + +const GET_QUERYSTRING_RESULTS = 'GET_QUERYSTRING_RESULTS'; +const RESET_QUERYSTRING_RESULTS = 'RESET_QUERYSTRING_RESULTS'; + +const initialState = { + error: null, + items: [], + total: 0, + loaded: false, + loading: false, + batching: {}, + subrequests: {}, +}; + +/** + * querystringsearch reducer. + * @function querystring + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export default function querystringsearch(state = initialState, action = {}) { + switch (action.type) { + case `${GET_QUERYSTRING_RESULTS}_PENDING`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + ...(state.subrequests[action.subrequest] || { + items: [], + total: 0, + batching: {}, + }), + items: [], + total: 0, + error: null, + loaded: false, + loading: true, + }, + }, + } + : { + ...state, + error: null, + loading: true, + loaded: false, + }; + case `${GET_QUERYSTRING_RESULTS}_SUCCESS`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + error: null, + items: map(action.result.items, (item) => ({ + ...item, + '@id': flattenToAppURL(item['@id']), + })), + facets_count: action.result.facets_count, + total: action.result.items_total, + loaded: true, + loading: false, + batching: { ...action.result.batching }, + }, + }, + } + : { + ...state, + error: null, + items: map(action.result.items, (item) => ({ + ...item, + '@id': flattenToAppURL(item['@id']), + })), + facets_count: action.result.facets_count, + total: action.result.items_total, + loaded: true, + loading: false, + batching: { ...action.result.batching }, + }; + case `${GET_QUERYSTRING_RESULTS}_FAIL`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + error: action.error, + items: [], + facets_count: {}, + total: 0, + loading: false, + loaded: false, + batching: {}, + }, + }, + } + : { + ...state, + error: action.error, + items: [], + facets_count: {}, + total: 0, + loading: false, + loaded: false, + batching: {}, + }; + case RESET_QUERYSTRING_RESULTS: + return action.subrequest + ? { + ...state, + subrequests: omit(state.subrequests, [action.subrequest]), + } + : { + ...state, + error: null, + items: [], + facets_count: {}, + total: 0, + loading: false, + loaded: false, + batching: {}, + }; + default: + return state; + } +}