diff --git a/package-lock.json b/package-lock.json index 2490d43c..f5b1b4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10913,6 +10913,11 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "highlight-words-core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/highlight-words-core/-/highlight-words-core-1.2.2.tgz", + "integrity": "sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==" + }, "history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", @@ -27009,6 +27014,23 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, + "react-highlight-words": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/react-highlight-words/-/react-highlight-words-0.16.0.tgz", + "integrity": "sha512-q34TwCSJOL+5pVDv6LUj3amaoyXdNDwd7zRqVAvceOrO9g1haWLAglK6WkGLMNUa3PFN8EgGedLg/k8Gpndxqg==", + "requires": { + "highlight-words-core": "^1.2.0", + "memoize-one": "^4.0.0", + "prop-types": "^15.5.8" + }, + "dependencies": { + "memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==" + } + } + }, "react-image-gallery": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/react-image-gallery/-/react-image-gallery-0.9.1.tgz", diff --git a/package.json b/package.json index 588fa778..1a8a6ff4 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "npm": "^6.13.7", "react": "^16.13.0", "react-component-queries": "^2.3.0", + "react-highlight-words": "^0.16.0", "react-image-gallery": "^0.9.1", "react-infinite-scroll-component": "^5.0.4", "react-lazy-load-image-component": "^1.4.0", diff --git a/src/actions/index.js b/src/actions/index.js index 3beded30..62b393cb 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -1,9 +1,12 @@ import { - SET_SECTION_TABS, - GET_PARENT_FOLDER_DATA, - GET_NAV_ITEMS + SET_SECTION_TABS, + GET_PARENT_FOLDER_DATA, + GET_NAV_ITEMS, + QUICK_RESET_SEARCH_CONTENT, + QUICK_SEARCH_CONTENT, } from '~/constants/ActionTypes'; +import { compact, concat, isArray, join, map, pickBy, toPairs } from 'lodash'; export function setSectionTabs(payload) { return { @@ -12,7 +15,6 @@ export function setSectionTabs(payload) { }; } - export function getParentFolderData(url) { return { type: GET_PARENT_FOLDER_DATA, @@ -22,3 +24,55 @@ export function getParentFolderData(url) { }, }; } + +export function quickSearchContent(url, options, subrequest = null) { + let queryArray = []; + const arrayOptions = pickBy(options, item => isArray(item)); + console.log(options, arrayOptions); + + queryArray = concat( + queryArray, + options + ? join( + map(toPairs(pickBy(options, item => !isArray(item))), item => { + if (item[0] === 'SearchableText') { + // Adds the wildcard to the SearchableText param + item[1] = `${item[1]}*`; + } + return join(item, '='); + }), + '&', + ) + : '', + ); + + queryArray = concat( + queryArray, + arrayOptions + ? join( + map(pickBy(arrayOptions), (item, key) => + join(item.map(value => `${key}:list=${value}`), '&'), + ), + '&', + ) + : '', + ); + + const querystring = join(compact(queryArray), '&'); + + return { + type: QUICK_SEARCH_CONTENT, + subrequest, + request: { + op: 'get', + path: `${url}/@search${querystring ? `?${querystring}` : ''}`, + }, + }; +} + +export function quickResetSearchContent(subrequest = null) { + return { + type: QUICK_RESET_SEARCH_CONTENT, + subrequest, + }; +} diff --git a/src/components/theme/View/TabsChildView.jsx b/src/components/theme/View/TabsChildView.jsx index 8c270e69..b0529e41 100644 --- a/src/components/theme/View/TabsChildView.jsx +++ b/src/components/theme/View/TabsChildView.jsx @@ -90,7 +90,7 @@ const DefaultView = props => { return hasBlocksData(content) ? (
-
+
diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js index 4aa5ef89..4ddbb776 100644 --- a/src/constants/ActionTypes.js +++ b/src/constants/ActionTypes.js @@ -1,5 +1,5 @@ export const SET_SECTION_TABS = 'SET_SECTION_TABS'; export const GET_PARENT_FOLDER_DATA = 'GET_PARENT_FOLDER_DATA'; export const GET_NAV_ITEMS = 'GET_NAV_ITEMS'; - - +export const QUICK_RESET_SEARCH_CONTENT = 'QUICK_RESET_SEARCH_CONTENT'; +export const QUICK_SEARCH_CONTENT = 'QUICK_SEARCH_CONTENT'; diff --git a/src/customizations/volto/components/theme/Search/Search.jsx b/src/customizations/volto/components/theme/Search/Search.jsx new file mode 100644 index 00000000..901f7ea3 --- /dev/null +++ b/src/customizations/volto/components/theme/Search/Search.jsx @@ -0,0 +1,324 @@ +/** + * Search component. + * @module components/theme/Search/Search + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { Link } from 'react-router-dom'; +import { asyncConnect } from 'redux-connect'; +import { FormattedMessage } from 'react-intl'; +import { Portal } from 'react-portal'; +import { Container, Pagination } from 'semantic-ui-react'; +import qs from 'query-string'; +import moment from 'moment'; + +import { settings } from '~/config'; +import { Helmet } from '@plone/volto/helpers'; +import { searchContent } from '@plone/volto/actions'; +import { + SearchTags, + SearchWidget, + Toolbar, + Icon, +} from '@plone/volto/components'; + +import paginationLeftSVG from '@plone/volto/icons/left-key.svg'; +import paginationRightSVG from '@plone/volto/icons/right-key.svg'; + +const toSearchOptions = (searchableText, subject, path) => { + return { + ...(searchableText && { SearchableText: searchableText }), + ...(subject && { + Subject: subject, + }), + ...(path && { + path: path, + }), + }; +}; + +/** + * Search class. + * @class SearchComponent + * @extends Component + */ +class Search extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + searchContent: PropTypes.func.isRequired, + searchableText: PropTypes.string, + subject: PropTypes.string, + path: PropTypes.string, + items: PropTypes.arrayOf( + PropTypes.shape({ + '@id': PropTypes.string, + '@type': PropTypes.string, + title: PropTypes.string, + description: PropTypes.string, + }), + ), + pathname: PropTypes.string.isRequired, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + items: [], + searchableText: null, + subject: null, + path: null, + }; + + constructor(props) { + super(props); + this.state = { currentPage: 1 }; + } + + /** + * Component will mount + * @method componentWillMount + * @returns {undefined} + */ + UNSAFE_componentWillMount() { + this.doSearch( + this.props.searchableText, + this.props.subject, + this.props.path, + ); + } + + /** + * Component will receive props + * @method componentWillReceiveProps + * @param {Object} nextProps Next properties + * @returns {undefined} + */ + UNSAFE_componentWillReceiveProps = nextProps => { + if ( + nextProps.searchableText !== this.props.searchableText || + nextProps.subject !== this.props.subject + ) { + this.doSearch( + nextProps.searchableText, + nextProps.subject, + this.props.path, + ); + } + }; + + /** + * Search based on the given searchableText, subject and path. + * @method doSearch + * @param {string} searchableText The searchable text string + * @param {string} subject The subject (tag) + * @param {string} path The path to restrict the search to + * @returns {undefined} + */ + + doSearch = (searchableText, subject, path) => { + this.setState({ currentPage: 1 }); + this.props.searchContent( + '', + toSearchOptions(searchableText, subject, path), + ); + }; + + handleQueryPaginationChange = (e, { activePage }) => { + window.scrollTo(0, 0); + this.setState({ currentPage: activePage }, () => { + const options = toSearchOptions( + qs.parse(this.props.location.search).SearchableText, + qs.parse(this.props.location.search).Subject, + qs.parse(this.props.location.search).path, + ); + + this.props.searchContent('', { + ...options, + b_start: (this.state.currentPage - 1) * settings.defaultPageSize, + }); + }); + }; + + getTitle = pathArray => { + let header = pathArray.slice(2, pathArray.length).join(' '); + return header + ? header.charAt(0).toUpperCase() + header.slice(1) + ' search result' + : null; + }; + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + return ( + + +
+
+
+

+ {this.getTitle(this.props.path.split('/')) ? ( +

+ {this.getTitle(this.props.path.split('/'))} +

+ ) : ( + + )} + + +
+ +
+ +
+ {this.props.searchableText ? ( + + Results for{' '} + + {this.props.searchableText} + + + ) : ( + '' + )} + {this.props.search?.items_total ? ( + {this.props.search.items_total} results + ) : ( + '' + )} +
+
+
+ {this.props.items.map(item => ( +
+

{item.title}

+
+ {item['@type'] ? {item['@type']} : ''} + {item['effective'] ? ( + + {moment(item['effective']).format('DD.MM.YYYY')} + + ) : ( + '' + )} +
+ {item.description && ( +
+ {item.description} +
+ )} +
+ + + +
+
+
+ ))} + + {this.props.search?.batching && ( +
+ , + icon: true, + 'aria-disabled': !this.props.search.batching.prev, + className: !this.props.search.batching.prev + ? 'disabled' + : null, + }} + nextItem={{ + content: , + icon: true, + 'aria-disabled': !this.props.search.batching.next, + className: !this.props.search.batching.next + ? 'disabled' + : null, + }} + /> +
+ )} +
+
+
+ + } + /> + +
+ ); + } +} + +export const __test__ = connect( + (state, props) => ({ + items: state.search.items, + searchableText: qs.parse(props.location.search).SearchableText, + subject: qs.parse(props.location.search).Subject, + path: qs.parse(props.location.search).path, + pathname: props.location.pathname, + }), + { searchContent }, +)(Search); + +export default compose( + connect( + (state, props) => ({ + items: state.search.items, + searchableText: qs.parse(props.location.search).SearchableText, + subject: qs.parse(props.location.search).Subject, + path: qs.parse(props.location.search).path, + pathname: props.location.pathname, + }), + { searchContent }, + ), + asyncConnect([ + { + key: 'search', + promise: ({ location, store: { dispatch } }) => + dispatch( + searchContent( + '', + toSearchOptions( + qs.parse(location.search).SearchableText, + qs.parse(location.search).Subject, + qs.parse(location.search).path, + ), + ), + ), + }, + ]), +)(Search); diff --git a/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx b/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx index a4f3b6b9..f20726bc 100644 --- a/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx +++ b/src/customizations/volto/components/theme/SearchWidget/SearchWidget.jsx @@ -9,9 +9,18 @@ import { Form, Input } from 'semantic-ui-react'; import { compose } from 'redux'; import { PropTypes } from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import qs from 'query-string'; import { Icon } from '@plone/volto/components'; import zoomSVG from '@plone/volto/icons/zoom.svg'; +import clearSVG from '@plone/volto/icons/clear.svg'; + +import { settings } from '~/config'; +import { quickResetSearchContent, quickSearchContent } from '~/actions'; + +import { doesNodeContainClick } from 'semantic-ui-react/dist/commonjs/lib'; +import Highlighter from 'react-highlight-words'; const messages = defineMessages({ search: { @@ -37,6 +46,8 @@ class SearchWidget extends Component { */ static propTypes = { pathname: PropTypes.string.isRequired, + quickResetSearchContent: PropTypes.func.isRequired, + quickSearchContent: PropTypes.func.isRequired, }; /** @@ -47,26 +58,54 @@ class SearchWidget extends Component { */ constructor(props) { super(props); - this.onChangeText = this.onChangeText.bind(this); this.onChangeSection = this.onChangeSection.bind(this); this.onSubmit = this.onSubmit.bind(this); + this.onChange = this.onChange.bind(this); + this.handleClickOutside = this.handleClickOutside.bind(this); this.state = { - text: '', + text: this.props.initialText || '', section: false, + active: false, + pathname: '', }; + this.linkFormContainer = React.createRef(); } - /** - * On change text - * @method onChangeText - * @param {object} event Event object. - * @param {string} value Text value. - * @returns {undefined} - */ - onChangeText(event, { value }) { - this.setState({ - text: value, - }); + componentDidMount() { + this.props.quickResetSearchContent(); + document.addEventListener('mousedown', this.handleClickOutside, false); + (this.props.pathname?.split('/')[1] === 'glossary' || + this.props.path?.split('/')[2] === 'glossary') && + this.setState({ + pathname: '/glossary', + section: true, + }); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside, false); + } + + handleClickOutside = e => { + if ( + this.linkFormContainer.current && + doesNodeContainClick(this.linkFormContainer.current, e) + ) { + this.setState({ active: true }); + } else { + this.setState({ active: false }); + } + }; + + onChange(event, { value }) { + if (value && value !== '') { + this.props.quickSearchContent(this.state.pathname, { + Title: `*${value}*`, + }); + } else { + this.props.quickResetSearchContent(); + } + this.setState({ text: value }); } /** @@ -82,6 +121,30 @@ class SearchWidget extends Component { }); } + componentDidUpdate(prevProps) { + if (prevProps.location?.state?.text && this.props?.location?.state?.text) { + if (prevProps.location.state.text !== this.props.location.state.text) { + this.setState( + { + text: this.props?.location?.state?.text, + }, + () => { + const section = this.state.section + ? `&path=${new URL(settings.apiPath).pathname + + this.state.pathname}` + : ''; + + this.props.history.push({ + pathname: `/search`, + search: `?SearchableText=${this.state.text}${section}`, + state: { text: this.state.text, section: section }, + }); + }, + ); + } + } + } + /** * Submit handler * @method onSubmit @@ -89,13 +152,33 @@ class SearchWidget extends Component { * @returns {undefined} */ onSubmit(event) { - const section = this.state.section ? `&path=${this.props.pathname}` : ''; - this.props.history.push( - `/search?SearchableText=${this.state.text}${section}`, - ); - event.preventDefault(); + const section = this.state.section + ? `&path=${new URL(settings.apiPath).pathname + this.state.pathname}` + : ''; + this.props.history.push({ + pathname: `/search`, + search: `?SearchableText=${this.state.text}${section}`, + state: { text: this.state.text, section: section }, + }); + this.setState({ active: false }); + event && event.preventDefault(); } + onSelectItem = item => { + this.setState( + { + text: item.title, + }, + () => this.onSubmit(), + ); + this.onClose(); + }; + + onClose = () => { + this.props.quickResetSearchContent(); + this.setState({ active: false }); + }; + /** * Render method. * @method render @@ -103,25 +186,87 @@ class SearchWidget extends Component { */ render() { return ( -
- - + + - - -
+ +
+ + + {this.state.text.length ? ( + { + this.setState({ + text: '', + }); + this.onClose(); + }} + /> + ) : ( + '' + )} + {this.state.active && + this.props.search && + this.props.search.length ? ( +
    + {this.props.search.map((item, index) => ( +
  • this.onSelectItem(item)} + role="presentation" + > + +
  • + ))} +
+ ) : ( + '' + )} +
+ +
+ +
); } } -export default compose(withRouter, injectIntl)(SearchWidget); \ No newline at end of file +export default compose( + withRouter, + injectIntl, + connect( + (state, props) => ({ + search: state.quicksearch.items, + path: qs.parse(props.location.search).path, + }), + { quickResetSearchContent, quickSearchContent }, + ), +)(SearchWidget); diff --git a/src/reducers/index.js b/src/reducers/index.js index 8ba4aec2..2c1aeef0 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,11 +1,13 @@ import defaultReducers from '@plone/volto/reducers'; import section_tabs from './section_tabs'; -import parent_folder_data from "./parent_folder_data" +import parent_folder_data from './parent_folder_data'; +import quicksearch from './quicksearch'; const reducers = { - section_tabs, - parent_folder_data, - ...defaultReducers -} + section_tabs, + parent_folder_data, + quicksearch, + ...defaultReducers, +}; -export default reducers \ No newline at end of file +export default reducers; diff --git a/src/reducers/quicksearch.js b/src/reducers/quicksearch.js new file mode 100644 index 00000000..3d9ad3bb --- /dev/null +++ b/src/reducers/quicksearch.js @@ -0,0 +1,131 @@ +/** + * Search reducer. + * @module reducers/search/search + */ + +import { map, omit } from 'lodash'; +import { settings } from '~/config'; + +import { + QUICK_RESET_SEARCH_CONTENT, + QUICK_SEARCH_CONTENT, +} from '~/constants/ActionTypes'; + +const initialState = { + error: null, + items: [], + total: 0, + loaded: false, + loading: false, + batching: {}, + subrequests: {}, +}; + +/** + * Search reducer. + * @function search + * @param {Object} state Current state. + * @param {Object} action Action to be handled. + * @returns {Object} New state. + */ +export default function search(state = initialState, action = {}) { + switch (action.type) { + case `${QUICK_SEARCH_CONTENT}_PENDING`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + ...(state.subrequests[action.subrequest] || { + items: [], + total: 0, + batching: {}, + }), + error: null, + loaded: false, + loading: true, + }, + }, + } + : { + ...state, + error: null, + loading: true, + loaded: false, + }; + case `${QUICK_SEARCH_CONTENT}_SUCCESS`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + error: null, + items: map(action.result.items, item => ({ + ...item, + '@id': item['@id'].replace(settings.apiPath, ''), + })), + total: action.result.items_total, + loaded: true, + loading: false, + batching: { ...action.result.batching }, + }, + }, + } + : { + ...state, + error: null, + items: map(action.result.items, item => ({ + ...item, + '@id': item['@id'].replace(settings.apiPath, ''), + })), + total: action.result.items_total, + loaded: true, + loading: false, + batching: { ...action.result.batching }, + }; + case `${QUICK_SEARCH_CONTENT}_FAIL`: + return action.subrequest + ? { + ...state, + subrequests: { + ...state.subrequests, + [action.subrequest]: { + error: action.error, + items: [], + total: 0, + loading: false, + loaded: false, + batching: {}, + }, + }, + } + : { + ...state, + error: action.error, + items: [], + total: 0, + loading: false, + loaded: false, + batching: {}, + }; + case QUICK_RESET_SEARCH_CONTENT: + return action.subrequest + ? { + ...state, + subrequests: omit(state.subrequests, [action.subrequest]), + } + : { + ...state, + error: null, + items: [], + total: 0, + loading: false, + loaded: false, + batching: {}, + }; + default: + return state; + } +} \ No newline at end of file diff --git a/src/routes.js b/src/routes.js index 8010f116..2e4a58d1 100644 --- a/src/routes.js +++ b/src/routes.js @@ -8,7 +8,6 @@ import { defaultRoutes } from '@plone/volto/routes'; import { addonRoutes } from '~/config'; import BrowseView from '~/components/theme/View/BrowseView/BrowseView'; - /** * Routes array. * @array @@ -24,8 +23,6 @@ const routes = [ path: '/browse', component: BrowseView, }, - - // { // path: '/', // component: HomepageView, diff --git a/theme/site/globals/site.overrides b/theme/site/globals/site.overrides index 029825ec..7eff7899 100644 --- a/theme/site/globals/site.overrides +++ b/theme/site/globals/site.overrides @@ -737,4 +737,119 @@ p { .block.maps iframe { height: unset!important; +} + +.floating_search_results { + position: absolute; + z-index: 98; + top: 100%; + width: 100%; + margin: 0; + list-style-type: none; + max-height: 264px; + overflow: auto; + margin: 0; + padding: 1rem; + padding-left: 4rem; + background: white; + color: #000; + border: 1px solid #EDEDED; + border-radius: 5px; + width: 100%; + -webkit-box-shadow: 0px 2px 4px -3px rgba(0,0,0,0.75); + -moz-box-shadow: 0px 2px 4px -3px rgba(0,0,0,0.75); + box-shadow: 0px 2px 4px -3px rgba(0,0,0.75); + li { + cursor: pointer; + font-size: .8rem; + line-height: 1.9; + margin-bottom: .5rem; + &:hover { + font-weight: bold; + } + } +} + +.ui.form.searchform { + position: relative!important; +} + +.glossary-search { + &.search-page { + margin: 0; + margin-bottom: 1rem; + width: 100%; + } + + .searchbox { + padding: 0!important; + div.input { + padding: 0 4rem; + border: 1px solid #606060; + } + + .icon { + fill: #606060!important; + position: absolute; + z-index: 1; + left: 1rem; + top: 50%; + transform: translateY(-50%); + } + + .clear.icon { + left: unset; + right: 1rem; + cursor: pointer; + } + } +} + +.search-meta { + margin-bottom: 3rem; + span { + color: #000; + margin-right: 2rem; + font-size: 0.9rem; + } +} + +.bold { + font-weight: bold; +} + +.ma-0 { + margin: 0!important; +} + +#page-search header { + margin-top: 3rem; + .documentFirstHeading { + margin-bottom: 2rem; + } +} + +button.outline { + background: transparent; + color: #000; + border: 1px solid #000; + padding: 0.6rem 2rem; + border-radius: 2rem; + cursor: pointer; + text-transform: uppercase; + &.dark-blue { + border: 1px solid #32536B; + &:hover { + background: #32536B; + color: #fff; + } + } + &:hover { + background: #000; + color: #fff; + } +} + +button.dark-blue { + color: #32536B; } \ No newline at end of file