@plone/volto
Version:
Volto
371 lines (350 loc) • 12 kB
JSX
/**
* 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 UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
import { asyncConnect } from '@plone/volto/helpers/AsyncConnect';
import { FormattedMessage } from 'react-intl';
import { createPortal } from 'react-dom';
import { Container, Pagination, Button, Header } from 'semantic-ui-react';
import qs from 'query-string';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import config from '@plone/volto/registry';
import Helmet from '@plone/volto/helpers/Helmet/Helmet';
import { searchContent } from '@plone/volto/actions/search/search';
import SearchTags from '@plone/volto/components/theme/Search/SearchTags';
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import paginationLeftSVG from '@plone/volto/icons/left-key.svg';
import paginationRightSVG from '@plone/volto/icons/right-key.svg';
const messages = defineMessages({
Search: {
id: 'Search',
defaultMessage: 'Search',
},
});
/**
* 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.defaultPageSize = config.settings.defaultPageSize;
this.state = { currentPage: 1, isClient: false, active: 'relevance' };
}
/**
* Component did mount
* @method componentDidMount
* @returns {undefined}
*/
componentDidMount() {
this.doSearch();
this.setState({ isClient: true });
}
/**
* Component will receive props
* @method componentWillReceiveProps
* @param {Object} nextProps Next properties
* @returns {undefined}
*/
UNSAFE_componentWillReceiveProps = (nextProps) => {
if (this.props.location.search !== nextProps.location.search) {
this.doSearch();
}
};
/**
* 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 = () => {
const options = qs.parse(this.props.history.location.search);
this.setState({ currentPage: 1 });
options['use_site_search_settings'] = 1;
this.props.searchContent('', {
b_size: this.defaultPageSize,
...options,
});
};
handleQueryPaginationChange = (e, { activePage }) => {
window.scrollTo(0, 0);
let options = qs.parse(this.props.history.location.search);
options['use_site_search_settings'] = 1;
this.setState({ currentPage: activePage }, () => {
this.props.searchContent('', {
b_size: this.defaultPageSize,
...options,
b_start:
(this.state.currentPage - 1) *
(options.b_size || this.defaultPageSize),
});
});
};
onSortChange = (event, sort_order) => {
let options = qs.parse(this.props.history.location.search);
options.sort_on = event.target.name;
options.sort_order = sort_order || 'ascending';
if (event.target.name === 'relevance') {
delete options.sort_on;
delete options.sort_order;
}
let searchParams = qs.stringify(options);
this.setState({ currentPage: 1, active: event.target.name }, () => {
// eslint-disable-next-line no-restricted-globals
this.props.history.replace({
search: searchParams,
});
});
};
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
const options = qs.parse(this.props.history.location.search);
return (
<Container id="page-search">
<Helmet title={this.props.intl.formatMessage(messages.Search)} />
<div className="container">
<article id="content">
<header>
<h1 className="documentFirstHeading">
{this.props.searchableText ? (
<FormattedMessage
id="Search results for {term}"
defaultMessage="Search results for {term}"
values={{
term: <q>{this.props.searchableText}</q>,
}}
/>
) : (
<FormattedMessage
id="Search results"
defaultMessage="Search results"
/>
)}
</h1>
<SearchTags />
{this.props.search?.items_total > 0 ? (
<div className="items_total">
{this.props.search.items_total}{' '}
<FormattedMessage
id="results found"
defaultMessage="results"
/>
<Header>
<Header.Content className="header-content">
<div className="sort-by">
<FormattedMessage
id="Sort By:"
defaultMessage="Sort by:"
/>
</div>
<Button
onClick={(event) => {
this.onSortChange(event);
}}
name="relevance"
size="tiny"
className={classNames('button-sort', {
'button-active': this.state.active === 'relevance',
})}
>
<FormattedMessage
id="Relevance"
defaultMessage="Relevance"
/>
</Button>
<Button
onClick={(event) => {
this.onSortChange(event);
}}
name="sortable_title"
size="tiny"
className={classNames('button-sort', {
'button-active':
this.state.active === 'sortable_title',
})}
>
<FormattedMessage
id="Alphabetically"
defaultMessage="Alphabetically"
/>
</Button>
<Button
onClick={(event) => {
this.onSortChange(event, 'reverse');
}}
name="effective"
size="tiny"
className={classNames('button-sort', {
'button-active': this.state.active === 'effective',
})}
>
<FormattedMessage
id="Date (newest first)"
defaultMessage="Date (newest first)"
/>
</Button>
</Header.Content>
</Header>
</div>
) : (
<div>
<FormattedMessage
id="No results found"
defaultMessage="No results found"
/>
</div>
)}
</header>
<section id="content-core">
{this.props.items.map((item) => (
<article className="tileItem" key={item['@id']}>
<h2 className="tileHeadline">
<UniversalLink
item={item}
className="summary url"
title={item['@type']}
>
{item.title}
</UniversalLink>
</h2>
{item.description && (
<div className="tileBody">
<span className="description">{item.description}</span>
</div>
)}
<div className="tileFooter">
<UniversalLink item={item}>
<FormattedMessage
id="Read More…"
defaultMessage="Read More…"
/>
</UniversalLink>
</div>
<div className="visualClear" />
</article>
))}
{this.props.search?.batching && (
<div className="search-footer">
<Pagination
activePage={this.state.currentPage}
totalPages={Math.ceil(
this.props.search.items_total /
(options.b_size || this.defaultPageSize),
)}
onPageChange={this.handleQueryPaginationChange}
firstItem={null}
lastItem={null}
prevItem={{
content: <Icon name={paginationLeftSVG} size="18px" />,
icon: true,
'aria-disabled': !this.props.search.batching.prev,
className: !this.props.search.batching.prev
? 'disabled'
: null,
}}
nextItem={{
content: <Icon name={paginationRightSVG} size="18px" />,
icon: true,
'aria-disabled': !this.props.search.batching.next,
className: !this.props.search.batching.next
? 'disabled'
: null,
}}
/>
</div>
)}
</section>
</article>
</div>
{this.state.isClient &&
createPortal(
<Toolbar
pathname={this.props.pathname}
hideDefaultViewButtons
inner={<span />}
/>,
document.getElementById('toolbar'),
)}
</Container>
);
}
}
export const __test__ = compose(
injectIntl,
connect(
(state, props) => ({
items: state.search.items,
searchableText: qs.parse(props.history.location.search).SearchableText,
pathname: props.history.location.pathname,
}),
{ searchContent },
),
)(Search);
export default compose(
injectIntl,
connect(
(state, props) => ({
items: state.search.items,
searchableText: qs.parse(props.history.location.search).SearchableText,
pathname: props.location.pathname,
}),
{ searchContent },
),
asyncConnect([
{
key: 'search',
promise: ({ location, store: { dispatch } }) =>
dispatch(
searchContent('', {
...qs.parse(location.search),
use_site_search_settings: 1,
}),
),
},
]),
)(Search);