UNPKG

design-comuni-plone-theme

Version:
737 lines (697 loc) 24.5 kB
/** * Search component. * @module components/theme/Search/Search */ import React, { useState, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useIntl, defineMessages } from 'react-intl'; import { values, isEmpty } from 'lodash'; import cx from 'classnames'; import qs from 'query-string'; import moment from 'moment'; import { Container, Row, Col, Collapse, CardCategory, Button, Toggle, Alert, Spinner, } from 'design-react-kit'; import { Skiplink, SkiplinkItem } from 'design-react-kit'; import { useLocation, useHistory } from 'react-router-dom'; import { Helmet, flattenToAppURL, BodyClass } from '@plone/volto/helpers'; import { resetSubsite } from 'volto-subsites'; import { Pagination, SearchSections, SearchTopics, SearchCTs, Icon, SearchResultItem, } from 'design-comuni-plone-theme/components/ItaliaTheme'; import { SearchUtils, TextInput, SelectInput, } from 'design-comuni-plone-theme/components'; import { getSearchFilters, getSearchResults, } from 'design-comuni-plone-theme/actions'; import { useDebouncedEffect } from 'design-comuni-plone-theme/helpers'; import config from '@plone/volto/registry'; const { parseFetchedSections, parseFetchedTopics, parseFetchedPortalTypes, parseFetchedOptions, getSearchParamsURL, } = SearchUtils; const messages = defineMessages({ search: { id: 'search', defaultMessage: 'Cerca', }, searchResults: { id: 'Search results', defaultMessage: 'Risultati della ricerca', }, searchSite: { id: 'Search site', defaultMessage: 'Cerca nel sito', }, sections: { id: 'sections', defaultMessage: 'Sezioni', }, topics: { id: 'topics', defaultMessage: 'Argomenti', }, options: { id: 'options', defaultMessage: 'Opzioni', }, removeOption: { id: 'removeOption', defaultMessage: 'Rimuovi opzione', }, optionActiveContentLabel: { id: 'optionActiveContentLabel', defaultMessage: 'Contenuti attivi', }, optionActiveContentInfo: { id: 'optionActiveContentInfo', defaultMessage: 'Verranno esclusi dalla ricerca i contenuti archiviati e non più validi come gli eventi terminati o i bandi scaduti.', }, optionDateStartButton: { id: 'optionDateStartButton', defaultMessage: 'Dal', }, optionDateEndButton: { id: 'optionDateEndButton', defaultMessage: 'Al', }, orderBy: { id: 'order_by', defaultMessage: 'Ordina per', }, sort_on_date: { id: 'sort_on_date', defaultMessage: 'Data (prima i più recenti)', }, sort_on_relevance: { id: 'sort_on_relevance', defaultMessage: 'Rilevanza', }, sort_on_title: { id: 'sort_on_title', defaultMessage: 'Alfabeticamente', }, foundNResults: { id: 'found_n_results', defaultMessage: 'Trovati {total} risultati.', }, filtersCollapse: { id: 'filtersCollapse', defaultMessage: 'Filtri', }, section_undefined: { id: 'section_undefined', defaultMessage: 'altro', }, attenzione: { id: 'Attenzione!', defaultMessage: 'Attenzione!' }, errors_occured: { id: 'Sono occorsi degli errori', defaultMessage: 'Sono occorsi degli errori', }, no_results: { id: 'Nessun risultato ottenuto', defaultMessage: 'Nessun risultato ottenuto', }, content_types: { id: 'search_content_types', defaultMessage: 'Tipologia', }, advFilters: { id: 'search_adv_filters', defaultMessage: 'Filtri avanzati', }, skipToSearchResults: { id: 'search_skip_to_search_results', defaultMessage: 'Vai ai risultati di ricerca', }, active_filters: { id: 'active_filters', defaultMessage: '{filterNumber} filtri attivati', }, }); const searchOrderDict = { relevance: {}, Date: { sort_on: 'Date', }, sortable_title: { sort_on: 'sortable_title', }, }; const Search = () => { const intl = useIntl(); const dispatch = useDispatch(); const location = useLocation(); const history = useHistory(); const [searchableText, setSearchableText] = useState( qs.parse(location.search)?.SearchableText ?? '', ); const [sections, setSections] = useState({}); const [topics, setTopics] = useState({}); const [portalTypes, setPortalTypes] = useState({}); const [options, setOptions] = useState({ ...SearchUtils.defaultOptions, ...parseFetchedOptions({}, location), }); const subsite = useSelector((state) => state.subsite?.data); const [customPath] = useState(qs.parse(location.search)?.custom_path ?? ''); const [sortOn, setSortOn] = useState( qs.parse(location.search)?.sort_on ?? 'relevance', ); const [currentPage, setCurrentPage] = useState( qs.parse(location.search)?.b_start ? qs.parse(location.search).b_start / config.settings.defaultPageSize + 1 : 1, ); const [collapseFilters, _setCollapseFilters] = useState(true); const [advFiltersOpen, setAdvFiltersOpen] = useState(false); const setCollapseFilters = (collapse) => { _setCollapseFilters(collapse); if (window?.innerWidth <= 991 && collapse) setTimeout( () => document .querySelector('main') ?.scrollIntoView?.({ behavior: 'smooth' }), 100, ); }; const sortOnOptions = [ { value: 'relevance', label: intl.formatMessage(messages.sort_on_relevance), }, { value: 'Date', label: intl.formatMessage(messages.sort_on_date), }, { value: 'sortable_title', label: intl.formatMessage(messages.sort_on_title), }, ]; const getSectionFromId = (id) => { let itemSection = Object.keys(sections).filter( (s) => flattenToAppURL(id).indexOf(s) > -1, ); if (itemSection?.length > 0) { const section = sections[itemSection[0]]; return <CardCategory href={section.path}>{section.title}</CardCategory>; } else { return ( <div className="category-top"> <span className="category"> {intl.formatMessage(messages.section_undefined)} </span> <span className="data"></span> </div> ); } }; const handleQueryPaginationChange = (_e, { activePage }) => { window.scrollTo(0, 0); setCurrentPage(activePage?.children ?? 1); }; const searchFilters = useSelector((state) => state.searchFilters.result); useEffect(() => { if (!searchFilters || Object.keys(searchFilters).length === 0) dispatch(getSearchFilters()); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (Object.keys(searchFilters?.sections ?? {}).length > 0) { setSections( parseFetchedSections(searchFilters.sections, location, subsite), ); } if (searchFilters?.topics?.length > 0) { setTopics(parseFetchedTopics(searchFilters.topics, location)); } if (searchFilters?.portal_types?.length > 0) { setPortalTypes( parseFetchedPortalTypes( searchFilters.portal_types, config.settings.defaultExcludedFromSearch?.portalTypes, location, ), ); } setOptions(parseFetchedOptions({}, location)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchFilters, subsite]); useEffect(() => { if ( subsite && !location.pathname.startsWith(flattenToAppURL(subsite['@id'])) ) { /*la ricerca è stata fatta dal sito padre, poi dai risultati si è passato a un subsite, poi è stato fatto back dal browser per tornare ai risultati di ricerca del sito padre*/ dispatch(resetSubsite()); } }, [subsite, dispatch, location.pathname]); const searchResults = useSelector((state) => state.searchResults); useDebouncedEffect( () => { doSearch(); }, // eslint-disable-next-line react-hooks/exhaustive-deps 600, [ dispatch, searchableText, sections, topics, options, sortOn, currentPage, portalTypes, ], ); const doSearch = () => { const queryString = getSearchParamsURL( searchableText?.length > 0 ? `${searchableText}*` : '', sections, topics, options, portalTypes, searchOrderDict[sortOn] ?? {}, currentPage, customPath, subsite, intl.locale, true, ); !isEmpty(searchResults.result) && history.push( getSearchParamsURL( searchableText, sections, topics, options, {}, searchOrderDict[sortOn] ?? {}, currentPage, customPath, subsite, intl.locale, false, ), ); dispatch(getSearchResults(queryString)); }; let activeSections = values(sections).reduce((acc, sec) => { return acc + values(sec.items).filter((i) => i.value).length; }, 0); let activeTopics = values(topics).filter((t) => t.value).length; let activePortalTypes = values(portalTypes).filter((ct) => ct.value).length; return ( <> <Helmet title={intl.formatMessage(messages.searchResults)} /> <div className="public-ui search-view" id="view"> <Container className="px-4 my-4"> <Row> <Col> <Row> <Col className="pb-3 pb-lg-5"> <h1>{intl.formatMessage(messages.searchResults)}</h1> </Col> </Row> <Row> <Col> <TextInput id="searchableText" label={intl.formatMessage(messages.searchSite)} value={searchableText} onChange={(id, value) => { setSearchableText(value); }} size="lg" prepend={ <Button icon tag="button" color="link" size="xs" className="rounded-0 py-0" onClick={doSearch} aria-label={intl.formatMessage(messages.search)} > <Icon color="" icon="it-search" padding={false} size="lg" /> </Button> } aria-controls="search-results-region" /> </Col> </Row> <Skiplink tag="div"> <SkiplinkItem href="#search-results-region" tag="a"> {intl.formatMessage(messages.skipToSearchResults)} </SkiplinkItem> </Skiplink> <div className="d-block d-lg-none d-xl-none"> <div className="row pb-3"> <div className="col-6"> {searchResults?.result?.items_total > 0 && ( <small aria-live="polite"> {intl.formatMessage(messages.foundNResults, { total: searchResults.result.items_total, })} </small> )} </div> <div className="col-6 align-self-center"> <div className="float-end"> <a onClick={() => setCollapseFilters((prev) => !prev)} href="#categoryCollapse" role="button" className={cx('btn btn-sm fw-bold text-uppercase', { 'btn-outline-primary': collapseFilters, 'btn-primary': !collapseFilters, })} data-toggle="collapse" aria-expanded={!collapseFilters} aria-controls="categoryCollapse" > {intl.formatMessage(messages.filtersCollapse)} </a> </div> </div> </div> </div> </Col> </Row> <Row> <aside className="col-lg-3 py-lg-5"> <div className="pe-4"></div> <Collapse isOpen={!collapseFilters} className="d-lg-block d-xl-block" id="categoryCollapse" > {Object.keys(sections)?.length > 0 && ( <div className="pt-4 pt-lg-0"> <h6 className="text-uppercase"> {intl.formatMessage(messages.sections)} <span className={cx('badge bg-secondary ms-3', { 'visually-hidden': activeSections === 0, })} aria-live="polite" aria-label={intl.formatMessage( messages.active_filters, { filterNumber: activeSections, }, )} > {activeSections} </span> </h6> <div className="mt-4"> <SearchSections sections={sections} setSections={setSections} toggleGroups={true} /> </div> </div> )} {Object.keys(topics)?.length > 0 && ( <div className={ Object.keys(sections)?.length > 0 ? 'pt-4 pt-lg-5' : 'pt-4 pt-lg-0' } > <h6 className="text-uppercase"> {intl.formatMessage(messages.topics)} <span className={cx('badge bg-secondary ms-3', { 'visually-hidden': activeTopics === 0, })} aria-live="polite" aria-label={intl.formatMessage( messages.active_filters, { filterNumber: activeTopics, }, )} > {activeTopics} </span> </h6> <div className="form-check mt-4"> <SearchTopics topics={topics} setTopics={setTopics} collapsable={true} /> </div> </div> )} {Object.keys(portalTypes).length > 0 && ( <div className="pt-5"> <Button color="secondary" outline icon size="small" onClick={() => setAdvFiltersOpen(!advFiltersOpen)} className="justify-content-start w-100 ps-2" aria-expanded={advFiltersOpen} > <Icon icon={advFiltersOpen ? 'it-minus' : 'it-plus'} padding /> {intl.formatMessage(messages.advFilters)} </Button> <Collapse isOpen={advFiltersOpen} id="advFilters"> <div className="p-3 shadow-sm bg-white"> <h6 className="text-uppercase"> {intl.formatMessage(messages.content_types)} <span className={cx('badge bg-secondary ms-3', { 'visually-hidden': activePortalTypes === 0, })} aria-live="polite" aria-label={intl.formatMessage( messages.active_filters, { filterNumber: activePortalTypes, }, )} > {activePortalTypes} </span> </h6> <div className="form-checck mt-4"> <SearchCTs portalTypes={portalTypes} setPortalTypes={setPortalTypes} collapsable /> </div> </div> </Collapse> </div> )} {values(options).filter((o) => o !== null && o !== undefined) .length > 0 && ( <div className="pt-4 pt-lg-5"> <h6 className="text-uppercase"> {intl.formatMessage(messages.options)} </h6> {options.activeContent !== undefined && ( <div className="form-check mt-4"> <Toggle label={intl.formatMessage( messages.optionActiveContentLabel, )} id="options-active-content" checked={options.activeContent} aria-controls="search-results-region" onChange={(e) => { const checked = e.currentTarget?.checked ?? false; setOptions((opts) => ({ ...opts, activeContent: checked, })); }} /> <p className="small"> {intl.formatMessage(messages.optionActiveContentInfo)} </p> </div> )} </div> )} <div className="form-check mt-4"> {options?.dateStart && ( <div role="presentation" className="chip chip-lg selected" onClick={() => setOptions((opts) => ({ ...opts, dateStart: null })) } > <span className="chip-label"> {`${intl.formatMessage( messages.optionDateStartButton, )} ${moment(options.dateStart) .locale(intl.locale) .format('LL')}`} </span> <button type="button"> <Icon color="" icon="it-close" padding={false} /> <span className="visually-hidden"> {intl.formatMessage(messages.removeOption)} </span> </button> </div> )} {options?.dateEnd && ( <div role="presentation" className="chip chip-lg selected" onClick={() => setOptions((opts) => ({ ...opts, dateEnd: null })) } > <span className="chip-label"> {`${intl.formatMessage( messages.optionDateEndButton, )} ${moment(options.dateEnd) .locale(intl.locale) .format('LL')}`} </span> <button type="button"> <Icon color="" icon="it-close" padding={false} /> <span className="visually-hidden"> {intl.formatMessage(messages.removeOption)} </span> </button> </div> )} </div> </Collapse> </aside> <Col lg={9} tag="section" className="py-lg-5"> <div className="search-results-wrapper" id="search-results-region" aria-live="polite" > <div className="d-block ordering-widget"> <Row className="pb-3 border-bottom"> <Col xs={6} className="align-self-center"> <p className="d-none d-lg-block" aria-live="polite"> {intl.formatMessage(messages.foundNResults, { total: searchResults?.result?.items_total || 0, })} </p> <p className="d-block d-lg-none mb-0 text-end"> {intl.formatMessage(messages.orderBy)} </p> </Col> <Col xs={6}> <SelectInput id="search-sort-on" value={ sortOnOptions.filter((o) => o.value === sortOn)[0] } label={intl.formatMessage(messages.orderBy)} placeholder={ sortOnOptions.find((o) => o.value === sortOn).label } onChange={(opt) => setSortOn(opt.value)} options={sortOnOptions} defaultValue={sortOnOptions.find( (o) => o.value === 'relevance', )} /> </Col> </Row> </div> {searchResults.loadingResults || (!searchResults.hasError && isEmpty(searchResults.result)) ? ( <div className="searchSpinnerWrapper"> <Spinner active /> </div> ) : searchResults?.result?.items_total > 0 ? ( <> <Row> {searchResults?.result?.items?.map((item, index) => ( <Col md={12} key={item['@id']} className="p-0"> <SearchResultItem item={item} index={index} searchableText={searchableText} section={getSectionFromId(item['@id'])} /> </Col> ))} </Row> {searchResults?.result?.batching && ( <Pagination activePage={currentPage} totalPages={Math.ceil( (searchResults?.result?.items_total ?? 0) / config.settings.defaultPageSize, )} onPageChange={handleQueryPaginationChange} /> )} </> ) : searchResults.error ? ( <Alert color="danger"> <strong>{intl.formatMessage(messages.attenzione)}</strong>{' '} {intl.formatMessage(messages.errors_occured)} </Alert> ) : ( !searchResults?.hasError && !isEmpty(searchResults?.result) && searchResults.result?.items.length === 0 && ( <p>{intl.formatMessage(messages.no_results)}</p> ) )} </div> </Col> </Row> </Container> </div> {/*force remove body class for subsite search pages*/} <BodyClass className="cms-ui" remove={true} /> </> ); }; export default Search;