@plone/volto
Version:
Volto
666 lines (637 loc) • 22.4 kB
JSX
/**
* Toolbar component.
* @module components/manage/Toolbar/Toolbar
*/
import React, { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import jwtDecode from 'jwt-decode';
import { connect } from 'react-redux';
import { compose } from 'redux';
import doesNodeContainClick from 'semantic-ui-react/dist/commonjs/lib/doesNodeContainClick';
import { withCookies } from 'react-cookie';
import filter from 'lodash/filter';
import find from 'lodash/find';
import cx from 'classnames';
import config from '@plone/volto/registry';
import More from '@plone/volto/components/manage/Toolbar/More';
import PersonalTools from '@plone/volto/components/manage/Toolbar/PersonalTools';
import Types from '@plone/volto/components/manage/Toolbar/Types';
import PersonalInformation from '@plone/volto/components/manage/Preferences/PersonalInformation';
import PersonalPreferences from '@plone/volto/components/manage/Preferences/PersonalPreferences';
import StandardWrapper from '@plone/volto/components/manage/Toolbar/StandardWrapper';
import { getTypes } from '@plone/volto/actions/types/types';
import { listActions } from '@plone/volto/actions/actions/actions';
import { setExpandedToolbar } from '@plone/volto/actions/toolbar/toolbar';
import { unlockContent } from '@plone/volto/actions/content/content';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass';
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
import { getCookieOptions } from '@plone/volto/helpers/Cookies/cookies';
import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
import { Pluggable } from '@plone/volto/components/manage/Pluggable';
import penSVG from '@plone/volto/icons/pen.svg';
import unlockSVG from '@plone/volto/icons/unlock.svg';
import folderSVG from '@plone/volto/icons/folder.svg';
import addSVG from '@plone/volto/icons/add-document.svg';
import moreSVG from '@plone/volto/icons/more.svg';
import userSVG from '@plone/volto/icons/user.svg';
import backSVG from '@plone/volto/icons/back.svg';
import clearSVG from '@plone/volto/icons/clear.svg';
const messages = defineMessages({
edit: {
id: 'Edit',
defaultMessage: 'Edit',
},
contents: {
id: 'Contents',
defaultMessage: 'Contents',
},
add: {
id: 'Add',
defaultMessage: 'Add',
},
more: {
id: 'More',
defaultMessage: 'More',
},
personalTools: {
id: 'Personal tools',
defaultMessage: 'Personal tools',
},
shrinkToolbar: {
id: 'Shrink toolbar',
defaultMessage: 'Shrink toolbar',
},
expandToolbar: {
id: 'Expand toolbar',
defaultMessage: 'Expand toolbar',
},
personalInformation: {
id: 'Personal Information',
defaultMessage: 'Personal Information',
},
personalPreferences: {
id: 'Personal Preferences',
defaultMessage: 'Personal Preferences',
},
collection: {
id: 'Collection',
defaultMessage: 'Collection',
},
file: {
id: 'File',
defaultMessage: 'File',
},
link: {
id: 'Link',
defaultMessage: 'Link',
},
newsItem: {
id: 'News Item',
defaultMessage: 'News Item',
},
page: {
id: 'Page',
defaultMessage: 'Page',
},
back: {
id: 'Back',
defaultMessage: 'Back',
},
unlock: {
id: 'Unlock',
defaultMessage: 'Unlock',
},
});
let toolbarComponents = {
personalTools: { component: PersonalTools, wrapper: null },
more: { component: More, wrapper: null },
types: { component: Types, wrapper: null, contentAsProps: true },
profile: {
component: PersonalInformation,
wrapper: StandardWrapper,
wrapperTitle: messages.personalInformation,
hideToolbarBody: true,
},
preferences: {
component: PersonalPreferences,
wrapper: StandardWrapper,
wrapperTitle: messages.personalPreferences,
hideToolbarBody: true,
},
};
/**
* Toolbar container class.
* @class Toolbar
* @extends Component
*/
class Toolbar extends Component {
/**
* Property types.
* @property {Object} propTypes Property types.
* @static
*/
static propTypes = {
actions: PropTypes.shape({
object: PropTypes.arrayOf(PropTypes.object),
object_buttons: PropTypes.arrayOf(PropTypes.object),
user: PropTypes.arrayOf(PropTypes.object),
}),
token: PropTypes.string,
userId: PropTypes.string,
pathname: PropTypes.string.isRequired,
content: PropTypes.shape({
'@type': PropTypes.string,
is_folderish: PropTypes.bool,
review_state: PropTypes.string,
}),
getTypes: PropTypes.func.isRequired,
types: PropTypes.arrayOf(
PropTypes.shape({
'@id': PropTypes.string,
addable: PropTypes.bool,
title: PropTypes.string,
}),
),
listActions: PropTypes.func.isRequired,
unlockContent: PropTypes.func,
unlockRequest: PropTypes.objectOf(PropTypes.any),
inner: PropTypes.element.isRequired,
hideDefaultViewButtons: PropTypes.bool,
};
/**
* Default properties.
* @property {Object} defaultProps Default properties.
* @static
*/
static defaultProps = {
actions: null,
token: null,
userId: null,
content: null,
hideDefaultViewButtons: false,
types: [],
};
toolbarRef = React.createRef();
toolbarWindow = React.createRef();
buttonRef = React.createRef();
constructor(props) {
super(props);
const { cookies } = props;
this.state = {
expanded: cookies.get('toolbar_expanded') !== 'false',
showMenu: false,
menuStyle: {},
menuComponents: [],
loadedComponents: [],
hideToolbarBody: false,
};
}
/**
* Component will mount
* @method componentDidMount
* @returns {undefined}
*/
componentDidMount() {
// Do not trigger the actions action if the expander is present
if (!hasApiExpander('actions', getBaseUrl(this.props.pathname))) {
this.props.listActions(getBaseUrl(this.props.pathname));
}
// Do not trigger the types action if the expander is present
if (!hasApiExpander('types', getBaseUrl(this.props.pathname))) {
this.props.getTypes(getBaseUrl(this.props.pathname));
}
toolbarComponents = {
...(config.settings
? config.settings.additionalToolbarComponents || {}
: {}),
...toolbarComponents,
};
this.props.setExpandedToolbar(this.state.expanded);
document.addEventListener('mousedown', this.handleClickOutside, false);
}
/**
* Component will receive props
* @method componentWillReceiveProps
* @param {Object} nextProps Next properties
* @returns {undefined}
*/
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.pathname !== this.props.pathname) {
// Do not trigger the actions action if the expander is present
if (!hasApiExpander('actions', getBaseUrl(nextProps.pathname))) {
this.props.listActions(getBaseUrl(nextProps.pathname));
}
// Do not trigger the types action if the expander is present
if (!hasApiExpander('types', getBaseUrl(nextProps.pathname))) {
this.props.getTypes(getBaseUrl(nextProps.pathname));
}
}
// Unlock
if (this.props.unlockRequest.loading && nextProps.unlockRequest.loaded) {
this.props.listActions(getBaseUrl(nextProps.pathname));
}
}
/**
* Component will receive props
* @method componentWillUnmount
* @returns {undefined}
*/
componentWillUnmount() {
document.removeEventListener('mousedown', this.handleClickOutside, false);
}
handleShrink = () => {
const { cookies } = this.props;
cookies.set('toolbar_expanded', !this.state.expanded, getCookieOptions());
this.setState(
(state) => ({ expanded: !state.expanded }),
() => this.props.setExpandedToolbar(this.state.expanded),
);
};
closeMenu = () => {
this.setState(() => ({ showMenu: false, loadedComponents: [] }));
};
loadComponent = (type) => {
const { loadedComponents } = this.state;
if (!this.state.loadedComponents.includes(type)) {
this.setState({
loadedComponents: [...loadedComponents, type],
hideToolbarBody: toolbarComponents[type].hideToolbarBody || false,
});
}
};
unloadComponent = () => {
this.setState((state) => ({
loadedComponents: state.loadedComponents.slice(0, -1),
hideToolbarBody:
toolbarComponents[
state.loadedComponents[state.loadedComponents.length - 2]
].hideToolbarBody || false,
}));
};
toggleButtonPressed = (e) => {
const target = e.target;
const button =
target.tagName === 'BUTTON'
? target
: this.findAncestor(e.target, 'button');
this.buttonRef.current = button;
};
toggleMenu = (e, selector) => {
if (this.state.showMenu) {
this.closeMenu();
return;
}
// PersonalTools always shows at bottom
if (selector === 'personalTools') {
this.setState((state) => ({
showMenu: !state.showMenu,
menuStyle: { bottom: 0 },
}));
} else if (selector === 'more') {
this.setState((state) => ({
showMenu: !state.showMenu,
menuStyle: {
overflow: 'visible',
top: 0,
},
}));
} else {
this.setState((state) => ({
showMenu: !state.showMenu,
menuStyle: { top: 0 },
}));
}
this.toggleButtonPressed(e);
this.loadComponent(selector);
};
findAncestor = (el, sel) => {
while (
(el = el.parentElement) &&
!(el.matches || el.matchesSelector).call(el, sel)
);
return el;
};
handleClickOutside = (e) => {
const target = e.target;
if (this.pusher && doesNodeContainClick(this.pusher, e)) return;
// if the click is on the same button, do not close the menu as it
// may be handled by the toggleMenu action
const button =
doesNodeContainClick(this.toolbarRef.current, e) &&
this.findAncestor(target, 'button');
if (button && button === this.buttonRef.current) return;
this.closeMenu();
};
unlock = (e) => {
this.props.unlockContent(getBaseUrl(this.props.pathname), true);
};
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
const path = getBaseUrl(this.props.pathname);
const lock = this.props.content?.lock;
const unlockAction =
lock?.locked && lock?.stealable && lock?.creator !== this.props.userId;
const editAction =
!unlockAction && find(this.props.actions.object, { id: 'edit' });
const folderContentsAction = find(this.props.actions.object, {
id: 'folderContents',
});
const { expanded } = this.state;
return (
this.props.token && (
<>
<BodyClass
className={expanded ? 'has-toolbar' : 'has-toolbar-collapsed'}
/>
<div
style={this.state.menuStyle}
className={
this.state.showMenu ? 'toolbar-content show' : 'toolbar-content'
}
ref={this.toolbarWindow}
>
{this.state.showMenu && (
// This sets the scroll locker in the body tag in mobile
<BodyClass className="has-toolbar-menu-open" />
)}
<div
className="pusher-puller"
ref={(node) => (this.pusher = node)}
style={{
transform: this.toolbarWindow.current
? `translateX(-${
(this.state.loadedComponents.length - 1) *
this.toolbarWindow.current.getBoundingClientRect().width
}px)`
: null,
}}
>
{this.state.loadedComponents.map((component, index) =>
(() => {
const ToolbarComponent =
toolbarComponents[component].component;
const WrapperComponent = toolbarComponents[component].wrapper;
const haveActions =
toolbarComponents[component].hideToolbarBody;
const title =
toolbarComponents[component].wrapperTitle &&
this.props.intl.formatMessage(
toolbarComponents[component].wrapperTitle,
);
if (WrapperComponent) {
return (
<WrapperComponent
componentName={component}
componentTitle={title}
pathname={this.props.pathname}
loadComponent={this.loadComponent}
unloadComponent={this.unloadComponent}
componentIndex={index}
theToolbar={this.toolbarWindow}
key={`personalToolsComponent-${index}`}
closeMenu={this.closeMenu}
hasActions={haveActions}
>
<ToolbarComponent
pathname={this.props.pathname}
loadComponent={this.loadComponent}
unloadComponent={this.unloadComponent}
componentIndex={index}
theToolbar={this.toolbarWindow}
closeMenu={this.closeMenu}
isToolbarEmbedded
/>
</WrapperComponent>
);
} else {
return (
<ToolbarComponent
pathname={this.props.pathname}
loadComponent={this.loadComponent}
unloadComponent={this.unloadComponent}
componentIndex={index}
theToolbar={this.toolbarWindow}
key={`personalToolsComponent-${index}`}
closeMenu={this.closeMenu}
content={
toolbarComponents[component].contentAsProps
? this.props.content
: null
}
/>
);
}
})(),
)}
</div>
</div>
<div
id="toolbar-body"
className={this.state.expanded ? 'toolbar expanded' : 'toolbar'}
ref={this.toolbarRef}
>
<div className="toolbar-body">
<div className="toolbar-actions">
{this.props.hideDefaultViewButtons && this.props.inner && (
<>{this.props.inner}</>
)}
{!this.props.hideDefaultViewButtons && (
<>
{unlockAction && (
<button
aria-label={this.props.intl.formatMessage(
messages.unlock,
)}
className="unlock"
onClick={(e) => this.unlock(e)}
tabIndex={0}
>
<Icon
name={unlockSVG}
size="30px"
className="unlock"
title={this.props.intl.formatMessage(messages.unlock)}
/>
</button>
)}
{editAction && (
<Link
aria-label={this.props.intl.formatMessage(
messages.edit,
)}
className="edit"
to={`${path}/edit`}
>
<Icon
name={penSVG}
size="30px"
className="circled"
title={this.props.intl.formatMessage(messages.edit)}
/>
</Link>
)}
{this.props.content &&
this.props.content.is_folderish &&
folderContentsAction &&
!this.props.pathname.endsWith('/contents') && (
<Link
aria-label={this.props.intl.formatMessage(
messages.contents,
)}
to={`${path}/contents`}
>
<Icon
name={folderSVG}
size="30px"
title={this.props.intl.formatMessage(
messages.contents,
)}
/>
</Link>
)}
{this.props.content &&
this.props.content.is_folderish &&
folderContentsAction &&
this.props.pathname.endsWith('/contents') && (
<Link
to={`${path}`}
aria-label={this.props.intl.formatMessage(
messages.back,
)}
>
<Icon
name={backSVG}
className="circled"
size="30px"
title={this.props.intl.formatMessage(messages.back)}
/>
</Link>
)}
{this.props.content &&
((this.props.content.is_folderish &&
this.props.types.length > 0) ||
(config.settings.isMultilingual &&
this.props.content['@components']?.translations)) && (
<button
className="add"
aria-label={this.props.intl.formatMessage(
messages.add,
)}
onClick={(e) => this.toggleMenu(e, 'types')}
tabIndex={0}
id="toolbar-add"
>
<Icon
name={addSVG}
size="30px"
title={this.props.intl.formatMessage(messages.add)}
/>
</button>
)}
<div className="toolbar-button-spacer" />
<button
className="more"
aria-label={this.props.intl.formatMessage(messages.more)}
onClick={(e) => this.toggleMenu(e, 'more')}
tabIndex={0}
id="toolbar-more"
>
<Icon
className="mobile hidden"
name={moreSVG}
size="30px"
title={this.props.intl.formatMessage(messages.more)}
/>
{this.state.showMenu ? (
<Icon
className="mobile only"
name={clearSVG}
size="30px"
/>
) : (
<Icon
className="mobile only"
name={moreSVG}
size="30px"
/>
)}
</button>
</>
)}
<Pluggable name="main.toolbar.top" />
</div>
<div className="toolbar-bottom">
<Pluggable
name="main.toolbar.bottom"
params={{ onClickHandler: this.toggleMenu }}
/>
{!this.props.hideDefaultViewButtons && (
<button
className="user"
aria-label={this.props.intl.formatMessage(
messages.personalTools,
)}
onClick={(e) => this.toggleMenu(e, 'personalTools')}
tabIndex={0}
id="toolbar-personal"
>
<Icon
name={userSVG}
size="30px"
title={this.props.intl.formatMessage(
messages.personalTools,
)}
/>
</button>
)}
</div>
</div>
<div className="toolbar-handler">
<button
className={cx('toolbar-handler-button', {
[this.props.content?.review_state]:
this.props.content?.review_state,
})}
onClick={this.handleShrink}
aria-expanded={expanded}
aria-controls="toolbar-body"
>
<span aria-live="assertive" className="visually-hidden">
{expanded
? this.props.intl.formatMessage(messages.shrinkToolbar)
: this.props.intl.formatMessage(messages.expandToolbar)}
</span>
</button>
</div>
</div>
<div className="pusher" />
</>
)
);
}
}
export default compose(
injectIntl,
withCookies,
connect(
(state, props) => ({
actions: state.actions.actions,
token: state.userSession.token,
userId: state.userSession.token
? jwtDecode(state.userSession.token).sub
: '',
content: state.content.data,
pathname: props.pathname,
types: filter(state.types.types, 'addable'),
unlockRequest: state.content.unlock,
}),
{ getTypes, listActions, setExpandedToolbar, unlockContent },
),
)(Toolbar);