UNPKG

@plone/volto

Version:
461 lines (438 loc) 13.7 kB
/** * ObjectBrowserWidget component. * @module components/manage/Widgets/ObjectBrowserWidget */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { compose } from 'redux'; import compact from 'lodash/compact'; import includes from 'lodash/includes'; import isArray from 'lodash/isArray'; import isEmpty from 'lodash/isEmpty'; import remove from 'lodash/remove'; import { connect } from 'react-redux'; import { Label, Popup, Button } from 'semantic-ui-react'; import { flattenToAppURL, isInternalURL, normalizeUrl, removeProtocol, } from '@plone/volto/helpers/Url/Url'; import { urlValidator } from '@plone/volto/helpers/FormValidation/validators'; import { searchContent } from '@plone/volto/actions/search/search'; import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; import { defineMessages, injectIntl } from 'react-intl'; import Icon from '@plone/volto/components/theme/Icon/Icon'; import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper'; import config from '@plone/volto/registry'; import navTreeSVG from '@plone/volto/icons/nav.svg'; import clearSVG from '@plone/volto/icons/clear.svg'; import homeSVG from '@plone/volto/icons/home.svg'; import aheadSVG from '@plone/volto/icons/ahead.svg'; import blankSVG from '@plone/volto/icons/blank.svg'; import { withRouter } from 'react-router'; import Image from '@plone/volto/components/theme/Image/Image'; const messages = defineMessages({ placeholder: { id: 'No items selected', defaultMessage: 'No items selected', }, edit: { id: 'Edit', defaultMessage: 'Edit', }, delete: { id: 'Delete', defaultMessage: 'Delete', }, openObjectBrowser: { id: 'Open object browser', defaultMessage: 'Open object browser', }, }); /** * ObjectBrowserWidget component class. * @class ObjectBrowserWidget * @extends Component */ export class ObjectBrowserWidgetComponent extends Component { /** * Property types. * @property {Object} propTypes Property types. * @static */ static propTypes = { id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, description: PropTypes.string, mode: PropTypes.string, // link, image, multiple return: PropTypes.string, // single, multiple initialPath: PropTypes.string, required: PropTypes.bool, error: PropTypes.arrayOf(PropTypes.string), value: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.object), PropTypes.object, ]), onChange: PropTypes.func.isRequired, openObjectBrowser: PropTypes.func.isRequired, allowExternals: PropTypes.bool, placeholder: PropTypes.string, onlyFolderishSelectable: PropTypes.bool, }; /** * Default properties * @property {Object} defaultProps Default properties. * @static */ static defaultProps = { description: null, required: false, error: [], value: [], mode: 'multiple', return: 'multiple', initialPath: '', allowExternals: false, onlyFolderishSelectable: false, }; state = { manualLinkInput: '', validURL: false, errors: [], }; constructor(props) { super(props); this.selectedItemsRef = React.createRef(); this.placeholderRef = React.createRef(); } renderLabel(item) { const href = item['@id']; return ( <Popup key={flattenToAppURL(href)} content={ <div style={{ display: 'flex' }}> {isInternalURL(href) ? ( <Icon name={homeSVG} size="18px" /> ) : ( <Icon name={blankSVG} size="18px" /> )} &nbsp; {flattenToAppURL(href)} </div> } trigger={ <Label> <div className="item-title"> {includes(config.settings.imageObjects, item['@type']) ? ( <Image className="small ui image" src={`${item['@id']}/@@images/image/thumb`} /> ) : ( item.title )} </div> <div> {this.props.mode === 'multiple' && ( <Icon name={clearSVG} size="12px" className="right" onClick={(event) => { event.preventDefault(); this.removeItem(item); }} /> )} </div> </Label> } /> ); } removeItem = (item) => { let value = [...this.props.value]; remove(value, function (_item) { return _item['@id'] === item['@id']; }); this.props.onChange(this.props.id, value); }; onChange = (item) => { let value = this.props.mode === 'multiple' && this.props.value ? [...this.props.value] : []; value = value.filter((item) => item != null); const maxSize = this.props.widgetOptions?.pattern_options?.maximumSelectionSize || -1; if (maxSize === 1 && value.length === 1) { value = []; //enable replace of selected item with another value, if maxsize is 1 } let exists = false; let index = -1; value.forEach((_item, _index) => { if (flattenToAppURL(_item['@id']) === flattenToAppURL(item['@id'])) { exists = true; index = _index; } }); //find(value, { // '@id': flattenToAppURL(item['@id']), // }); if (!exists) { // add item // Check if we want to filter the attributes of the selected item let resultantItem = item; if (this.props.selectedItemAttrs) { const allowedItemKeys = [ ...this.props.selectedItemAttrs, // Add the required attributes for the widget to work '@id', 'title', ]; resultantItem = Object.keys(item) .filter((key) => allowedItemKeys.includes(key)) .reduce((obj, key) => { obj[key] = item[key]; return obj; }, {}); } // Add required @id field, just in case resultantItem = { ...resultantItem, '@id': item['@id'] }; value.push(resultantItem); if (this.props.return === 'single') { this.props.onChange(this.props.id, value[0]); } else { this.props.onChange(this.props.id, value); } } else { //remove item value.splice(index, 1); this.props.onChange(this.props.id, value); } }; onManualLinkInput = (e) => { this.setState({ manualLinkInput: e.target.value }); if (this.validateManualLink(e.target.value)) { this.setState({ validURL: true }); } else { this.setState({ validURL: false }); } }; validateManualLink = (url) => { if (this.props.allowExternals && !url.startsWith('/')) { const error = urlValidator({ value: url, formatMessage: this.props.intl.formatMessage, }); if (error && url !== '') { this.setState({ errors: [error] }); } else { this.setState({ errors: [] }); } return !Boolean(error); } else { return isInternalURL(url); } }; onSubmitManualLink = () => { if (this.validateManualLink(this.state.manualLinkInput)) { if (isInternalURL(this.state.manualLinkInput)) { const link = this.state.manualLinkInput; // convert it into an internal on if possible this.props .searchContent( '/', { 'path.query': flattenToAppURL(this.state.manualLinkInput), 'path.depth': '0', sort_on: 'getObjPositionInParent', metadata_fields: '_all', b_size: 1000, }, `${this.props.block}-${this.props.mode}`, ) .then((resp) => { if (resp.items?.length > 0) { this.onChange(resp.items[0]); } else { this.props.onChange(this.props.id, [ { '@id': flattenToAppURL(link), title: removeProtocol(link), }, ]); } }); } else { this.props.onChange(this.props.id, [ { '@id': normalizeUrl(this.state.manualLinkInput), title: removeProtocol(this.state.manualLinkInput), }, ]); } this.setState({ validURL: true, manualLinkInput: '' }); } }; onKeyDownManualLink = (e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.onSubmitManualLink(); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); // TODO: Do something on ESC key } }; showObjectBrowser = (ev) => { ev.preventDefault(); this.props.openObjectBrowser({ mode: this.props.mode, currentPath: this.props.initialPath || this.props.location.pathname, propDataName: 'value', onSelectItem: (url, item) => { this.onChange(item); }, selectableTypes: this.props.widgetOptions?.pattern_options?.selectableTypes || this.props.selectableTypes, maximumSelectionSize: this.props.widgetOptions?.pattern_options?.maximumSelectionSize || this.props.maximumSelectionSize, onlyFolderishSelectable: this.props.widgetOptions?.pattern_options?.onlyFolderishSelectable || this.props.onlyFolderishSelectable, }); }; handleSelectedItemsRefClick = (e) => { if (this.props.isDisabled) { return; } if ( e.target.contains(this.selectedItemsRef.current) || e.target.contains(this.placeholderRef.current) ) { this.showObjectBrowser(e); } }; /** * Render method. * @method render * @returns {string} Markup for the component. */ render() { const { id, description, fieldSet, value, mode, onChange, isDisabled } = this.props; let items = compact(!isArray(value) && value ? [value] : value || []); let icon = mode === 'multiple' || items.length === 0 ? navTreeSVG : clearSVG; let iconAction = mode === 'multiple' || items.length === 0 ? this.showObjectBrowser : (e) => { e.preventDefault(); onChange(id, this.props.return === 'single' ? null : []); }; return ( <FormFieldWrapper {...this.props} // At the moment, OBW handles its own errors and validation error={this.state.errors} className={description ? 'help text' : 'text'} > <div className="objectbrowser-field" aria-labelledby={`fieldset-${ fieldSet || 'default' }-field-label-${id}`} > <div className="selected-values" onClick={this.handleSelectedItemsRefClick} onKeyDown={this.handleSelectedItemsRefClick} role="searchbox" tabIndex={0} ref={this.selectedItemsRef} > {items.map((item) => this.renderLabel(item))} {items.length === 0 && this.props.mode === 'multiple' && ( <div className="placeholder" ref={this.placeholderRef}> {this.props.placeholder ?? this.props.intl.formatMessage(messages.placeholder)} </div> )} {this.props.allowExternals && items.length === 0 && this.props.mode !== 'multiple' && ( <input onBlur={this.onSubmitManualLink} onKeyDown={this.onKeyDownManualLink} onChange={this.onManualLinkInput} value={this.state.manualLinkInput} placeholder={ this.props.placeholder ?? this.props.intl.formatMessage(messages.placeholder) } /> )} </div> {this.state.manualLinkInput && isEmpty(items) && ( <Button.Group> <Button basic className="cancel" onClick={(e) => { e.stopPropagation(); this.setState({ manualLinkInput: '' }); }} > <Icon name={clearSVG} size="18px" color="#e40166" /> </Button> <Button basic primary disabled={!this.state.validURL} onClick={(e) => { e.stopPropagation(); this.onSubmitManualLink(); }} > <Icon name={aheadSVG} size="18px" /> </Button> </Button.Group> )} {!this.state.manualLinkInput && ( <Button aria-label={this.props.intl.formatMessage( messages.openObjectBrowser, )} onClick={iconAction} className="action" disabled={isDisabled} > <Icon name={icon} size="18px" /> </Button> )} </div> </FormFieldWrapper> ); } } const ObjectBrowserWidgetMode = (mode) => compose( injectIntl, withObjectBrowser, withRouter, connect(null, { searchContent }), )((props) => <ObjectBrowserWidgetComponent {...props} mode={mode} />); export { ObjectBrowserWidgetMode }; export default compose( injectIntl, withObjectBrowser, withRouter, connect(null, { searchContent }), )(ObjectBrowserWidgetComponent);