UNPKG

@plone/volto

Version:
639 lines (612 loc) 19.6 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { Input, Segment, Breadcrumb } from 'semantic-ui-react'; import join from 'lodash/join'; // These absolute imports (without using the corresponding centralized index.js) are required // to cut circular import problems, this file should never use them. This is because of // the very nature of the functionality of the component and its relationship with others import { searchContent } from '@plone/volto/actions/search/search'; import Icon from '@plone/volto/components/theme/Icon/Icon'; import { flattenToAppURL, isInternalURL } from '@plone/volto/helpers/Url/Url'; import config from '@plone/volto/registry'; import backSVG from '@plone/volto/icons/back.svg'; import folderSVG from '@plone/volto/icons/folder.svg'; import clearSVG from '@plone/volto/icons/clear.svg'; import searchSVG from '@plone/volto/icons/zoom.svg'; import linkSVG from '@plone/volto/icons/link.svg'; import homeSVG from '@plone/volto/icons/home.svg'; import iconsSVG from '@plone/volto/icons/apps.svg'; import listSVG from '@plone/volto/icons/list-bullet.svg'; import ObjectBrowserNav from '@plone/volto/components/manage/Sidebar/ObjectBrowserNav'; const messages = defineMessages({ SearchInputPlaceholder: { id: 'Search content', defaultMessage: 'Search content', }, SelectedItems: { id: 'Selected items', defaultMessage: 'Selected items', }, back: { id: 'Back', defaultMessage: 'Back', }, search: { id: 'Search SVG', defaultMessage: 'Search SVG', }, iconView: { id: 'Icon View', defaultMessage: 'Icon View', }, listView: { id: 'List View', defaultMessage: 'List View', }, home: { id: 'Home', defaultMessage: 'Home', }, of: { id: 'Selected items - x of y', defaultMessage: 'of' }, }); function getParentURL(url) { return flattenToAppURL(`${join(url.split('/').slice(0, -1), '/')}`) || '/'; } /** * ObjectBrowserBody container class. * @class ObjectBrowserBody * @extends Component */ class ObjectBrowserBody extends Component { /** * Property types. * @property {Object} propTypes Property types. * @static */ static propTypes = { block: PropTypes.string.isRequired, mode: PropTypes.string.isRequired, data: PropTypes.any.isRequired, searchSubrequests: PropTypes.objectOf(PropTypes.any).isRequired, searchContent: PropTypes.func.isRequired, closeObjectBrowser: PropTypes.func.isRequired, onChangeBlock: PropTypes.func.isRequired, onSelectItem: PropTypes.func, dataName: PropTypes.string, maximumSelectionSize: PropTypes.number, contextURL: PropTypes.string, searchableTypes: PropTypes.arrayOf(PropTypes.string), onlyFolderishSelectable: PropTypes.bool, }; /** * Default properties. * @property {Object} defaultProps Default properties. * @static */ static defaultProps = { image: '', href: '', onSelectItem: null, dataName: null, selectableTypes: [], searchableTypes: null, maximumSelectionSize: null, onlyFolderishSelectable: false, }; /** * Constructor * @method constructor * @param {Object} props Component properties * @constructs WysiwygEditor */ constructor(props) { super(props); this.state = { currentFolder: this.props.mode === 'multiple' ? '/' : this.props.contextURL || '/', currentImageFolder: this.props.mode === 'multiple' ? '/' : this.props.mode === 'image' && this.props.data?.url ? getParentURL(this.props.data.url) : '/', currentLinkFolder: this.props.mode === 'multiple' ? '/' : this.props.mode === 'link' && this.props.data?.href ? getParentURL(this.props.data.href) : '/', parentFolder: '', selectedImage: this.props.mode === 'multiple' ? '' : this.props.mode === 'image' && this.props.data?.url ? flattenToAppURL(this.props.data.url) : '', selectedHref: this.props.mode === 'multiple' ? '' : this.props.mode === 'link' && this.props.data?.href ? flattenToAppURL(this.props.data.href) : '', showSearchInput: false, // In image mode, the searchable types default to the image types which // can be overridden with the property if specified. // If selectableTypes are passed, the searchableTypes are the selectableTypes searchableTypes: this.props.mode === 'image' ? this.props.searchableTypes || config.settings.imageObjects : [ ...(this.props.searchableTypes ?? []), ...(this.props.selectableTypes ?? []), ], view: this.props.mode === 'image' ? 'icons' : 'list', }; this.searchInputRef = React.createRef(); } /** * Component did mount * @method componentDidMount * @returns {undefined} */ componentDidMount() { this.initialSearch(this.props.mode); } initialSearch = (mode) => { const currentSelected = mode === 'multiple' ? '' : mode === 'image' ? this.state.selectedImage : this.state.selectedHref; if (currentSelected && isInternalURL(currentSelected)) { this.props.searchContent( getParentURL(currentSelected), { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', b_size: 1000, }, `${this.props.block}-${mode}`, ); } else { this.props.searchContent( this.state.currentFolder, { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', b_size: 1000, }, `${this.props.block}-${mode}`, ); } }; navigateTo = (id) => { this.props.searchContent( id, { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', b_size: 1000, }, `${this.props.block}-${this.props.mode}`, ); const parent = `${join(id.split('/').slice(0, -1), '/')}` || '/'; this.setState(() => ({ parentFolder: parent, currentFolder: id || '/', })); }; toggleSearchInput = () => this.setState( (prevState) => ({ showSearchInput: !prevState.showSearchInput, }), () => { if (this.searchInputRef?.current) { this.searchInputRef.current.focus(); } else { this.props.searchContent( this.state.currentFolder, { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', b_size: 1000, }, `${this.props.block}-${this.props.mode}`, ); } }, ); toggleView = () => this.setState((prevState) => ({ view: prevState.view === 'icons' ? 'list' : 'icons', })); onSearch = (e) => { const text = flattenToAppURL(e.target.value); if (text.startsWith('/')) { this.setState({ currentFolder: text }); this.props.searchContent( text, { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', portal_type: this.state.searchableTypes, }, `${this.props.block}-${this.props.mode}`, ); } else { text.length > 2 ? this.props.searchContent( '/', { SearchableText: `${text}*`, metadata_fields: '_all', portal_type: this.state.searchableTypes, }, `${this.props.block}-${this.props.mode}`, ) : this.props.searchContent( '/', { 'path.depth': 1, sort_on: 'getObjPositionInParent', metadata_fields: '_all', portal_type: this.state.searchableTypes, }, `${this.props.block}-${this.props.mode}`, ); } }; onSelectItem = (item) => { const url = item['@id']; const { block, data, mode, dataName, onChangeBlock } = this.props; const updateState = (mode) => { switch (mode) { case 'image': this.setState({ selectedImage: url, currentImageFolder: getParentURL(url), }); break; case 'link': this.setState({ selectedHref: url, currentLinkFolder: getParentURL(url), }); break; default: break; } }; if (dataName) { onChangeBlock(block, { ...data, [dataName]: url, }); } else if (this.props.onSelectItem) { this.props.onSelectItem(url, item); } else if (mode === 'image') { onChangeBlock(block, { ...data, url: flattenToAppURL(item.getURL), alt: '', }); } else if (mode === 'link') { onChangeBlock(block, { ...data, href: flattenToAppURL(url), }); } updateState(mode); }; onChangeBlockData = (key, value) => { this.props.onChangeBlock(this.props.block, { ...this.props.data, [key]: value, }); }; isSelectable = (item) => { const { maximumSelectionSize, data, mode, selectableTypes, onlyFolderishSelectable, } = this.props; if (onlyFolderishSelectable && !item.is_folderish) { return false; } if ( maximumSelectionSize && data && mode === 'multiple' && maximumSelectionSize <= data.length ) // The item should actually be selectable, but only for removing it from already selected items list. // handleClickOnItem will handle the deselection logic. // The item is not selectable if we reached/exceeded maximumSelectionSize and is not already selected. return data.some( (d) => flattenToAppURL(d['@id']) === flattenToAppURL(item['@id']), ); return selectableTypes.length > 0 ? selectableTypes.indexOf(item['@type']) >= 0 : true; }; handleClickOnItem = (item) => { if (this.props.mode === 'image') { if (item.is_folderish) { this.navigateTo(item['@id']); } if (config.settings.imageObjects.includes(item['@type'])) { this.onSelectItem(item); } } else { if (this.isSelectable(item)) { if ( !this.props.maximumSelectionSize || this.props.mode === 'multiple' || !this.props.data || this.props.data.length <= this.props.maximumSelectionSize ) { let isDeselecting; if (this.props.mode === 'multiple' && Array.isArray(this.props.data)) isDeselecting = this.props.data.some( (d) => flattenToAppURL(d['@id']) === flattenToAppURL(item['@id']), ); this.onSelectItem(item); let length = this.props.data ? this.props.data.length : 0; let stopSelecting = this.props.mode !== 'multiple'; if (isDeselecting && !stopSelecting) stopSelecting = this.props.maximumSelectionSize > 0 && length - 1 >= this.props.maximumSelectionSize; else stopSelecting = this.props.maximumSelectionSize > 0 && length + 1 >= this.props.maximumSelectionSize; if (stopSelecting) { this.props.closeObjectBrowser(); } } else { this.props.closeObjectBrowser(); } } else { this.navigateTo(item['@id']); } } }; handleDoubleClickOnItem = (item) => { if (this.props.mode === 'image') { if (item.is_folderish) { this.navigateTo(item['@id']); } if (config.settings.imageObjects.includes(item['@type'])) { this.onSelectItem(item); this.props.closeObjectBrowser(); } } else { if (this.isSelectable(item)) { if (this.props.data.length < this.props.maximumSelectionSize) { this.onSelectItem(item); } this.props.closeObjectBrowser(); } else { this.navigateTo(item['@id']); } } }; /** * Render method. * @method render * @returns {string} Markup for the component. */ render() { return ( <Segment.Group raised className="object-browser"> <header className="header pulled"> <div className="vertical divider" /> {this.state.showSearchInput ? ( <Input className="search" ref={this.searchInputRef} onChange={this.onSearch} placeholder={this.props.intl.formatMessage( messages.SearchInputPlaceholder, )} /> ) : ( <> {this.state.currentFolder === '/' ? ( <> {this.props.mode === 'image' ? ( <Icon name={folderSVG} size="24px" /> ) : ( <Icon name={linkSVG} size="24px" /> )} </> ) : ( <button aria-label={this.props.intl.formatMessage(messages.back)} onClick={() => this.navigateTo(this.state.parentFolder)} > <Icon name={backSVG} size="24px" /> </button> )} {this.props.mode === 'image' ? ( <h2> <FormattedMessage id="Choose Image" defaultMessage="Choose Image" /> </h2> ) : ( <h2> <FormattedMessage id="Choose Target" defaultMessage="Choose Target" /> </h2> )} </> )} <button aria-label={this.props.intl.formatMessage(messages.search)} onClick={this.toggleSearchInput} > <Icon name={searchSVG} size="24px" /> </button> <button className="clearSVG" onClick={this.props.closeObjectBrowser}> <Icon name={clearSVG} size="24px" /> </button> </header> <Segment secondary className="breadcrumbs" vertical> {this.props.mode === 'image' && ( <button onClick={this.toggleView} className="mode-switch" aria-label={this.props.intl.formatMessage( this.state.view === 'list' ? messages.iconView : messages.listView, )} > <Icon name={this.state.view === 'list' ? iconsSVG : listSVG} size="24px" className="mode-switch" title={this.props.intl.formatMessage( this.state.view === 'list' ? messages.iconView : messages.listView, )} /> </button> )} {!this.state.showSearchInput ? ( <Breadcrumb> {this.state.currentFolder !== '/' ? ( this.state.currentFolder .split('/') .map((item, index, items) => { return ( <React.Fragment key={`divider-${item}-${index}`}> {index === 0 ? ( <Breadcrumb.Section onClick={() => this.navigateTo('/')} role="button" aria-label={this.props.intl.formatMessage( messages.home, )} > <Icon className="home-icon" name={homeSVG} size="18px" title={this.props.intl.formatMessage( messages.home, )} /> </Breadcrumb.Section> ) : ( <> <Breadcrumb.Divider key={`divider-${item.url}`} /> <Breadcrumb.Section role="button" onClick={() => this.navigateTo( items.slice(0, index + 1).join('/'), ) } > {item} </Breadcrumb.Section> </> )} </React.Fragment> ); }) ) : ( <Breadcrumb.Section onClick={() => this.navigateTo('/')} aria-label={this.props.intl.formatMessage(messages.home)} > <Icon className="home-icon" name={homeSVG} role="button" size="18px" title={this.props.intl.formatMessage(messages.home)} /> </Breadcrumb.Section> )} </Breadcrumb> ) : ( <div className="searchResults"> <FormattedMessage id="Search results" defaultMessage="Search results" /> </div> )} </Segment> {this.props.mode === 'multiple' && ( <Segment className="infos"> {this.props.intl.formatMessage(messages.SelectedItems)}:{' '} {this.props.data?.length} {this.props.maximumSelectionSize > 0 && ( <> {' '} {this.props.intl.formatMessage(messages.of)}{' '} {this.props.maximumSelectionSize} </> )} </Segment> )} <ObjectBrowserNav currentSearchResults={ this.props.searchSubrequests[ `${this.props.block}-${this.props.mode}` ] } selected={ this.props.mode === 'multiple' ? this.props.data : [ { '@id': this.props.mode === 'image' ? this.state.selectedImage : this.state.selectedHref, }, ] } handleClickOnItem={this.handleClickOnItem} handleDoubleClickOnItem={this.handleDoubleClickOnItem} mode={this.props.mode} view={this.state.view} navigateTo={this.navigateTo} isSelectable={this.isSelectable} /> </Segment.Group> ); } } export default compose( injectIntl, connect( (state) => ({ searchSubrequests: state.search.subrequests, lang: state.intl.locale, }), { searchContent }, ), )(ObjectBrowserBody);