From 41b7b19dcf22cbae7df713240a4158e07352522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Re=C3=A9?= Date: Mon, 27 May 2024 15:28:45 +0200 Subject: [PATCH] Add support for an autocomplete livesearch widget --- news/25.feature | 1 + package.json | 4 +- src/actions/index.js | 3 +- .../solrsearch/solrSearchSuggestions.js | 11 + src/components/index.js | 3 + .../theme/SolrSearch/SolrSearch.jsx | 68 +++- .../theme/SolrSearchWidget/MailTo.jsx | 53 +++ .../SolrSearchWidget/SolrSearchWidget.jsx | 346 ++++++++++++++++++ .../theme/SearchWidget/SearchWidget.jsx | 9 + src/icons/person.svg | 16 + src/icons/search.svg | 14 + src/index.js | 6 + src/reducers/index.js | 2 + .../solrsearch/solrSearchSuggestions.js | 38 ++ src/theme/solrsearch.less | 220 +++++++++++ 15 files changed, 776 insertions(+), 18 deletions(-) create mode 100644 news/25.feature create mode 100644 src/actions/solrsearch/solrSearchSuggestions.js create mode 100644 src/components/theme/SolrSearchWidget/MailTo.jsx create mode 100644 src/components/theme/SolrSearchWidget/SolrSearchWidget.jsx create mode 100644 src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx create mode 100644 src/icons/person.svg create mode 100644 src/icons/search.svg create mode 100644 src/reducers/solrsearch/solrSearchSuggestions.js diff --git a/news/25.feature b/news/25.feature new file mode 100644 index 0000000..b8a61bb --- /dev/null +++ b/news/25.feature @@ -0,0 +1 @@ +Add support for an autocomplete livesearch widget @reebalazs diff --git a/package.json b/package.json index c8f33e9..0c4f430 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,12 @@ }, "devDependencies": { "@plone/scripts": "^2.3.0", + "react-autosuggest": "10.1.0", "release-it": "^16.1.0" }, "peerDependencies": { - "@plone/volto": "^16.20.0 || ^17.0.0-alpha.4" + "@plone/volto": "^16.20.0 || ^17.0.0-alpha.4", + "react-autosuggest": "10.1.0" }, "packageManager": "yarn@3.5.1" } diff --git a/src/actions/index.js b/src/actions/index.js index 8688b1a..6104706 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -1,2 +1,3 @@ import { solrSearchContent, copyContentForSolr } from './solrsearch/solrsearch'; -export { solrSearchContent, copyContentForSolr }; +import { solrSearchSuggestions } from './solrsearch/solrSearchSuggestions'; +export { solrSearchContent, copyContentForSolr, solrSearchSuggestions }; diff --git a/src/actions/solrsearch/solrSearchSuggestions.js b/src/actions/solrsearch/solrSearchSuggestions.js new file mode 100644 index 0000000..c686733 --- /dev/null +++ b/src/actions/solrsearch/solrSearchSuggestions.js @@ -0,0 +1,11 @@ +export const GET_SOLR_SEARCH_SUGGESTIONS = 'GET_SOLR_SEARCH_SUGGESTIONS'; + +export function solrSearchSuggestions(term) { + return { + type: GET_SOLR_SEARCH_SUGGESTIONS, + request: { + op: 'get', + path: `/@solr-suggest?query=${term}`, + }, + }; +} diff --git a/src/components/index.js b/src/components/index.js index c6f74e0..d62408c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,2 +1,5 @@ export SolrSearch from '@kitconcept/volto-solr/components/theme/SolrSearch/SolrSearch'; export SolrFormattedDate from '@kitconcept/volto-solr/components/theme/SolrSearch/resultItems/helpers/SolrFormattedDate'; +export SolrSearchWidget, { + SolrSearchAutosuggest, +} from '@kitconcept/volto-solr/components/theme/SolrSearchWidget/SolrSearchWidget'; diff --git a/src/components/theme/SolrSearch/SolrSearch.jsx b/src/components/theme/SolrSearch/SolrSearch.jsx index a91b325..35b2c9f 100644 --- a/src/components/theme/SolrSearch/SolrSearch.jsx +++ b/src/components/theme/SolrSearch/SolrSearch.jsx @@ -65,19 +65,51 @@ const messages = defineMessages({ }, }); +const SearchInput = forwardRef( + ({ forwardRef, placeholder, className, value, onChange, onSubmit }, ref) => { + const SolrSearchAutosuggest = config.widgets.SolrSearchAutosuggest; + return ( + + ); + }, +); + +// XXX The original input - left here for testing. +// const SearchInputDefault = forwardRef( +// ({ forwardRef, placeholder, className, value, onChange }, ref) => { +// return ( +// +// ); +// }, +// ); + // XXX for some reason formatMessage is missing from this.props.intl. // Until we figure this out, just acquire it directly from hook. // This should not be necessary.. @reebalazs const TranslatedInput = forwardRef( - ({ forwardRef, placeholder, className, value, onChange }, ref) => { + ({ forwardRef, placeholder, className, value, onChange, onSubmit }, ref) => { const intl = useIntl(); return ( - ); }, @@ -236,6 +268,7 @@ class SolrSearch extends Component { path_prefix: getPathPrefix(window.location), doEmptySearch: this.props.doEmptySearch, }); + this.setState({ searchwordResult: params.SearchableText }); }; updateSearch = () => { @@ -301,19 +334,22 @@ class SolrSearch extends Component { {this.props.showSearchInput ? (
-
- - this.setState({ searchword: e.target.value }) - } - /> - + +
+ + this.setState({ searchword: e.target.value }) + } + onSubmit={this.onSubmit} + /> + +
) : null} @@ -325,7 +361,7 @@ class SolrSearch extends Component { /> ) : null} { + let decoded; + if (!email.includes('@')) { + const buffer = Buffer.from(email, 'base64'); + decoded = buffer.toString('ascii'); + } + + const intl = useIntl(); + + const rot10 = (s) => { + return s.replace( + /[a-z]/g, + (c) => + 'abcdefghijklmnopqrstuvwxyz'['klmnopqrstuvwxyzabcdefghij'.indexOf(c)], + ); + }; + + const onClickEmail = (e) => { + const emailClear = email.includes('@') ? email : rot10(decoded); + window.open(`mailto:${emailClear}`, '_self'); + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + onClickEmail(e)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onClickEmail(); + } + }} + title={intl.formatMessage(messages.openMailTo)} + role="button" + tabIndex={0} + > + + E-Mail + + ); +}; diff --git a/src/components/theme/SolrSearchWidget/SolrSearchWidget.jsx b/src/components/theme/SolrSearchWidget/SolrSearchWidget.jsx new file mode 100644 index 0000000..ecbe7f3 --- /dev/null +++ b/src/components/theme/SolrSearchWidget/SolrSearchWidget.jsx @@ -0,0 +1,346 @@ +/** + * Solr Search widget component. + * @module components/theme/SearchWidget/SearchWidget + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { withRouter, Link } from 'react-router-dom'; +import { Form } from 'semantic-ui-react'; +import { compose } from 'redux'; +import { + defineMessages, + injectIntl, + FormattedMessage, + useIntl, +} from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import { solrSearchSuggestions } from '../../../actions'; +import Autosuggest from 'react-autosuggest'; +import cx from 'classnames'; +import MailTo from './MailTo'; + +import { flattenToAppURL } from '@plone/volto/helpers'; +import { Icon } from '@plone/volto/components'; +import qs from 'query-string'; +import config from '@plone/volto/registry'; + +import searchSVG from '../../../icons/search.svg'; +import personSVG from '../../../icons/person.svg'; +import debounce from 'lodash.debounce'; + +const messages = defineMessages({ + search: { + id: 'Search', + defaultMessage: 'Search', + }, + searchSite: { + id: 'Search Site', + defaultMessage: 'Search Site', + }, + showAllSearchResults: { + id: 'Show all search results...', + defaultMessage: 'Show all search results...', + }, +}); + +// -- +// SolrSearchAutosuggest is factored ouf from SolrSearchWidget, and it contains +// only the input field, so we can include it better from Search.jsx. +// -- + +const ID_ALL = '@ALL'; +// Items to show. Note this must be <= 10 as 10 items will +// always be fetched currently. +const NR_ITEMS = 8; + +const SolrSearchAutosuggestRaw = (props) => { + const originalText = props.value; + const dispatch = useDispatch(); + let suggestions = useSelector((state) => + state.solrSearchSuggestions.items.slice(0, NR_ITEMS), + ).concat({ + '@type': 'ShowAll', + '@id': ID_ALL, + title: originalText, + }); + + const shortenURL = (url) => { + const arr = url.split('/'); + let str; + if (arr.length >= 5) { + str = `${arr[0]}/${arr[1]}/${arr[2]}/... /${arr.pop()}`; + } else { + arr.pop(); + str = arr.join('/'); + } + return '/' + str; + }; + + const renderSuggestion = (suggestion) => { + const getIcon = (type) => + config.settings.contentTypeSearchResultIcons[type] || + config.settings.contentTypeSearchResultDefaultIcon; + const getContentTypeTitle = (contentType) => + props.intl.formatMessage({ + id: contentType, + defaultMessage: contentType, + }); + + return ( + <> + {suggestion['@type'] === 'ShowAll' && suggestions.length > 1 ? ( + + ) : suggestion['@type'] === 'Member' ? ( +
+ + {suggestion.image ? ( + {suggestion.title} + ) : ( +
+ +
+ )} + +
+ + {`${suggestion.salutation ? suggestion.salutation : ''} ${ + suggestion.academic ? suggestion.academic : '' + } + ${suggestion.title}`} + +
+ {suggestion.phone && ( + Tel. {suggestion.phone} |  + )} + {suggestion.email && ( + + )} + {suggestion.institute && ( + {suggestion.institute} |  + )} + + {suggestion.building && `Geb.${suggestion.building}`} + {suggestion.room && `/R.${suggestion.room}`} + +
+
+
+ ) : ( + suggestion['@type'] !== 'ShowAll' && ( +
+
+ + + {getContentTypeTitle(suggestion['@type'])} + +
+
+
+ {shortenURL(flattenToAppURL(suggestion['@id']))} +
+
{suggestion.title}
+
+
+ ) + )} + + ); + }; + + const cancelRequest = useRef(null); + const debounceCustom = useRef( + debounce((value) => { + if (cancelRequest.current) { + cancelRequest.current.request.abort(); + cancelRequest.current = null; + } + cancelRequest.current = dispatch(solrSearchSuggestions(value)); + }, 300), + ); + + const onSuggestionsFetchRequested = ({ value, reason }) => { + debounceCustom.current(value); + + if (reason === 'suggestion-selected') { + props.history.push(flattenToAppURL(value['@id'])); + } + }; + + const getSuggestionValue = (suggestion) => suggestion; + + const onSubmit = (event) => { + if (props.inputRef) { + // props.inputRef.current.value = ''; + // Only search when text is provided - do not navigate to empty search results. + // Important: Blur the input, otherwise the dropdown will reopen and never close. + props.inputRef.current.blur(); + } + if (props.onSubmit) { + props.onSubmit(event); + } + event.preventDefault(); + }; + + const ref = useRef(); + + // react-autosuggest does not seem to support an onSubmit handler + const onKeyDown = (event) => { + if (event.key === 'Enter') { + // We want to onSubmit **ONLY** when enter is pressed in the input. + // NOT when enter is pressed on any of the drowdown items, or on the + // All Items line. This makes sure that the dialog disappears when + // we pressed enter in the input, but pressing input on any other + // line does the navigation as it should. + const { highlightedSuggestionIndex } = ref.current?.state; + if (highlightedSuggestionIndex == null) { + onSubmit(event); + return false; + } + } + return true; + }; + + const onSuggestionSelected = ( + event, + { suggestion, suggestionValue, suggestionIndex, sectionIndex, method }, + ) => { + const id = suggestion['@id']; + if (id === ID_ALL) { + onSubmit(event); + event.preventDefault(); + } else { + props.history.push(flattenToAppURL(id)); + event.preventDefault(); + } + }; + + const inputProps = { + placeholder: props.placeholder, + value: props.value, + onChange: props.onChange, + onFocus: props.onFocus, + onBlur: props.onBlur, + onKeyDown: onKeyDown, + }; + + const storeInputReference = (autosuggest) => { + if (autosuggest) { + if (props.inputRef) { + props.inputRef.current = autosuggest.input; + } + ref.current = autosuggest; + } + }; + + return ( + {}} + renderSuggestion={renderSuggestion} + getSuggestionValue={getSuggestionValue} + inputProps={inputProps} + onSuggestionSelected={onSuggestionSelected} + onSubmit={onSubmit} + /> + ); +}; + +export const SolrSearchAutosuggest = compose( + withRouter, + injectIntl, +)(SolrSearchAutosuggestRaw); + +// -- +// SolrSearchWidget would be needed if we wanted the search widget +// in the header of each page, as originally supported by Volto. +// -- + +const SolrSearchWidget = (props) => { + const intl = useIntl(); + const [focused, setFocused] = useState(false); + const chainedOnFocus = props?.onFocus; + const onFocus = useCallback(() => { + setFocused(true); + if (chainedOnFocus) { + chainedOnFocus(); + } + }, [setFocused, chainedOnFocus]); + const chainedOnBlur = props?.onBlur; + const onBlur = useCallback(() => { + setFocused(false); + if (chainedOnBlur) { + chainedOnBlur(); + } + }, [setFocused, chainedOnBlur]); + const [text, setText] = useState(''); + // const [originalText, setOriginalText] = useState(''); + + const onChange = (event, { newValue, method }) => { + // method === 'type' && setOriginalText(newValue); + typeof newValue === 'string' ? setText(newValue) : setText(newValue.title); + }; + + useEffect(() => { + if (props.location.pathname === '/search') { + const SearchableText = qs.parse(props.location.search)?.SearchableText; + if (SearchableText?.length > 0 && text.length === 0) { + setText(SearchableText); + } + } else { + setText(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.location.pathname]); + + const onSubmit = (event) => { + // Only search when text is provided - do not navigate to empty search results. + if (text) { + props.history.push( + `/search?SearchableText=${text}&metadata_fields=room&metadata_fields=building&metadata_fields=phone&metadata_fields=email&metadata_fields=getIcon&metadata_fields=institute`, + ); + } + event.preventDefault(); + }; + + return ( +
+
+ + + + +
+
+ ); +}; + +export default compose(withRouter, injectIntl)(SolrSearchWidget); diff --git a/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx b/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx new file mode 100644 index 0000000..d57b734 --- /dev/null +++ b/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx @@ -0,0 +1,9 @@ +import config from '@plone/volto/registry'; + +const SearchWidget = (props) => { + console.log('rendering SearchWidget...', props); + const { SolrSearchWidget } = config.widgets; + return ; +}; + +export default SearchWidget; diff --git a/src/icons/person.svg b/src/icons/person.svg new file mode 100644 index 0000000..32ea20e --- /dev/null +++ b/src/icons/person.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 0000000..fcb94c2 --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,14 @@ + + + Fill 66 Copy 2 + + + + + + diff --git a/src/index.js b/src/index.js index afd1f12..e2f7e4c 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ import { import { SolrSearch, SolrFormattedDate, + SolrSearchWidget, + SolrSearchAutosuggest, } from '@kitconcept/volto-solr/components'; import * as searchResultItems from '@kitconcept/volto-solr/components/theme/SolrSearch/resultItems'; import fileSVG from '@plone/volto/icons/file.svg'; @@ -64,6 +66,10 @@ const applyConfig = (config) => { config.widgets.SolrSearch = SolrSearch; config.widgets.SolrFormattedDate = SolrFormattedDate; + // Autocomplete widget + config.widgets.SolrSearchWidget = SolrSearchWidget; + config.widgets.SolrSearchAutosuggest = SolrSearchAutosuggest; + config.addonReducers = { ...config.addonReducers, ...reducers }; config.addonRoutes = [...config.addonRoutes, ...routes(config)]; diff --git a/src/reducers/index.js b/src/reducers/index.js index 4f35c0c..f3d6158 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,4 +1,5 @@ import solrsearch from './solrsearch/solrsearch'; +import solrSearchSuggestions from './solrsearch/solrSearchSuggestions'; import { defineMessages } from 'react-intl'; // needed to add as overrides are not parsed by i18n @@ -34,6 +35,7 @@ defineMessages({ const reducers = { solrsearch, + solrSearchSuggestions, }; export default reducers; diff --git a/src/reducers/solrsearch/solrSearchSuggestions.js b/src/reducers/solrsearch/solrSearchSuggestions.js new file mode 100644 index 0000000..59cfe98 --- /dev/null +++ b/src/reducers/solrsearch/solrSearchSuggestions.js @@ -0,0 +1,38 @@ +import { GET_SOLR_SEARCH_SUGGESTIONS } from '../../actions/solrsearch/solrSearchSuggestions'; + +const initialState = { + error: null, + items: [], + loaded: false, + loading: false, +}; + +export default function searchSuggestions(state = initialState, action = {}) { + switch (action.type) { + case `${GET_SOLR_SEARCH_SUGGESTIONS}_PENDING`: + return { + ...state, + error: null, + loaded: false, + loading: true, + }; + case `${GET_SOLR_SEARCH_SUGGESTIONS}_SUCCESS`: + return { + ...state, + error: null, + items: action.result.suggestions, + loaded: true, + loading: false, + }; + case `${GET_SOLR_SEARCH_SUGGESTIONS}_FAIL`: + return { + ...state, + error: action.error, + items: [], + loaded: false, + loading: false, + }; + default: + return state; + } +} diff --git a/src/theme/solrsearch.less b/src/theme/solrsearch.less index 76d13d5..568ef64 100644 --- a/src/theme/solrsearch.less +++ b/src/theme/solrsearch.less @@ -10,6 +10,23 @@ @solr-highlight-weight: var(--solr-highlight-weight, 600); @solr-footer-color: var(--solr-footer-color, #808080); +@solr-autosuggest-darkblue-color: var( + --solr-autosuggest-darkblue-color, + #023d6b +); +@solr-autosuggest-black-color: var(--solr-autosuggest-black-color, #000); +@solr-autosuggest-lightgrey-color: var( + --solr-autosuggest-lightgrey-color, + #466377 +); +@solr-autosuggest-grey-color: var(--solr-autosuggest-grey-color, #333); + +// TBD consolidate these with vars +@computerBreakpoint: 1066px; +@largestMobileScreen: 1065px; +@fontSize: 16px; +@fontSizeH3: 20px; + .section-search, .section-\@\@search { .total-bar { @@ -391,3 +408,206 @@ } } } + +// -- +// Search autosuggest +// -- + +.search-widget, +.search-input { + position: relative; + display: flex; + width: 50%; + height: 64px; + align-items: center; + border-bottom: 3px solid @solr-autosuggest-darkblue-color; + margin: 0 auto; + transition: all 600ms ease 0s; + + &.focused { + width: calc(50% + 2rem); + padding-right: 1rem; + padding-left: 1rem; + box-shadow: 0px 10px 30px 10px rgba(0, 0, 0, 0.2); + } + + /* Tablet portrait & landscape */ + @media only screen and (max-width: @computerBreakpoint) { + width: calc(100% - 220px); + margin: 24px auto 0; + + &.focused { + width: calc(100% - 220px + 2rem); + } + } + + /* Mobile */ + @media only screen and (max-width: @largestMobileScreen) { + width: calc(100% - 2rem); + margin-top: 24px; + + &.focused { + width: 100%; + } + } + + .ui.form { + width: 100%; + } + + .ui.form .field.searchbox { + align-items: center; + padding-left: 0; + border-left: 0px; + + .react-autosuggest__container { + width: 100%; + + .react-autosuggest__input { + padding: 4px 0 0; + border: none; + font-size: @fontSizeH3; + text-overflow: ellipsis; + + &.react-autosuggest__input--focused { + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + } + + /* Mobile */ + @media only screen and (max-width: @largestMobileScreen) { + font-size: @fontSize; + } + } + + .react-autosuggest__suggestions-container { + position: absolute; + z-index: 99; + top: 64px; + left: 0; + width: 100%; + padding-left: 0; + background: #fff; + box-shadow: 0px 6px 10px 0px rgba(0, 0, 0, 0.75); + font-size: 1rem; + /* FONT TBD */ + + .all-results-button { + padding: 10px 0 10px 65px; + } + + .react-autosuggest__suggestions-list { + padding: 0; + margin: 0; + list-style: none; + + .react-autosuggest__suggestion { + &.react-autosuggest__suggestion--highlighted { + background: #dbdbdb; + } + + .suggestion { + display: flex; + width: 100%; + align-items: flex-start; + padding: 5px; + color: @solr-autosuggest-black-color; + + .icon-wrapper { + display: flex; + min-width: 60px; + max-width: 60px; + flex-direction: column; + justify-content: space-between; + padding-top: 3px; + color: @solr-autosuggest-lightgrey-color; + text-align: center; + + span { + font-size: 9pt; + line-height: 18px; + word-wrap: break-word; + } + + &.placeholder svg { + margin-right: 0; + } + } + + .suggestion-path { + color: @solr-autosuggest-lightgrey-color; + } + } + } + + .member { + display: flex; + align-items: center; + color: @solr-autosuggest-grey-color; + + .image-wrapper { + display: flex; + justify-content: center; + + img { + min-width: 50px; + height: 50px; + margin-right: 5px; + margin-left: 5px; + border-radius: 50%; + object-fit: cover; + object-position: top center; + } + } + + .member-body { + display: flex; + // height: 60px; + flex-direction: column; + justify-content: space-between; + object-fit: cover; + + a { + color: @solr-autosuggest-grey-color; + + &.member-title { + color: @solr-autosuggest-black-color; + font-weight: bold; + } + } + + .member-info { + display: flex; + flex-wrap: wrap; + align-items: center; + + a { + color: @solr-autosuggest-darkblue-color; + } + + svg { + margin-right: 4px; + margin-bottom: 3px; + color: @solr-autosuggest-grey-color; + } + } + } + } + } + } + } + + button { + display: flex; + align-items: center; + + .icon { + margin-right: 0; + } + } + } + + @media only print { + display: none; + } +}