@plone/volto
Version:
Volto
639 lines (612 loc) • 19.6 kB
JSX
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);