UNPKG

@plone/volto

Version:
343 lines (318 loc) 10.7 kB
/** * App container. * @module components/theme/App/App */ import React, { Component } from 'react'; import jwtDecode from 'jwt-decode'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose } from 'redux'; import { asyncConnect } from '@plone/volto/helpers/AsyncConnect'; import Helmet from '@plone/volto/helpers/Helmet/Helmet'; import { Segment } from 'semantic-ui-react'; import { renderRoutes } from 'react-router-config'; import { Slide, ToastContainer, toast } from 'react-toastify'; import split from 'lodash/split'; import join from 'lodash/join'; import trim from 'lodash/trim'; import cx from 'classnames'; import config from '@plone/volto/registry'; import { PluggablesProvider } from '@plone/volto/components/manage/Pluggable'; import { visitBlocks } from '@plone/volto/helpers/Blocks/Blocks'; import { injectIntl } from 'react-intl'; import Error from '@plone/volto/error'; import Breadcrumbs from '@plone/volto/components/theme/Breadcrumbs/Breadcrumbs'; import Footer from '@plone/volto/components/theme/Footer/Footer'; import Header from '@plone/volto/components/theme/Header/Header'; import Icon from '@plone/volto/components/theme/Icon/Icon'; import OutdatedBrowser from '@plone/volto/components/theme/OutdatedBrowser/OutdatedBrowser'; import AppExtras from '@plone/volto/components/theme/AppExtras/AppExtras'; import SkipLinks from '@plone/volto/components/theme/SkipLinks/SkipLinks'; import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass'; import { getBaseUrl, getView, isCmsUi } from '@plone/volto/helpers/Url/Url'; import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils'; import { getBreadcrumbs } from '@plone/volto/actions/breadcrumbs/breadcrumbs'; import { getContent } from '@plone/volto/actions/content/content'; import { getNavigation } from '@plone/volto/actions/navigation/navigation'; import { getTypes } from '@plone/volto/actions/types/types'; import { getWorkflow } from '@plone/volto/actions/workflow/workflow'; import clearSVG from '@plone/volto/icons/clear.svg'; import MultilingualRedirector from '@plone/volto/components/theme/MultilingualRedirector/MultilingualRedirector'; import WorkingCopyToastsFactory from '@plone/volto/components/manage/WorkingCopyToastsFactory/WorkingCopyToastsFactory'; import LockingToastsFactory from '@plone/volto/components/manage/LockingToastsFactory/LockingToastsFactory'; import RouteAnnouncer from '@plone/volto/components/theme/RouteAnnouncer/RouteAnnouncer'; /** * @export * @class App * @extends {Component} */ export class App extends Component { /** * Property types. * @property {Object} propTypes Property types. * @static */ static propTypes = { pathname: PropTypes.string.isRequired, }; state = { hasError: false, error: null, errorInfo: null, }; constructor(props) { super(props); this.mainRef = React.createRef(); } /** * @method componentWillReceiveProps * @param {Object} nextProps Next properties * @returns {undefined} */ UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.pathname !== this.props.pathname) { if (this.state.hasError) { this.setState({ hasError: false }); } } } /** * ComponentDidCatch * @method ComponentDidCatch * @param {string} error The error * @param {string} info The info * @returns {undefined} */ componentDidCatch(error, info) { this.setState({ hasError: true, error, errorInfo: info }); config.settings.errorHandlers.forEach((handler) => handler(error)); } dispatchContentClick = (event) => { if (event.target === event.currentTarget) { const rect = this.mainRef.current.getBoundingClientRect(); if (event.clientY > rect.bottom) { document.dispatchEvent(new Event('voltoClickBelowContent')); } } }; /** * Render method. * @method render * @returns {string} Markup for the component. */ render() { const { views } = config; const path = getBaseUrl(this.props.pathname); const action = getView(this.props.pathname); const isCmsUI = isCmsUi(this.props.pathname); const ConnectionRefusedView = views.errorViews.ECONNREFUSED; const language = this.props.content?.language?.token ?? this.props.intl?.locale; return ( <PluggablesProvider> {language && ( <Helmet> <html lang={language} /> </Helmet> )} <BodyClass className={`view-${action}view`} /> {/* Body class depending on content type */} {this.props.content && this.props.content['@type'] && ( <BodyClass className={`contenttype-${this.props.content['@type'] .replaceAll(' ', '-') .toLowerCase()}`} /> )} {/* Body class depending on sections */} <BodyClass className={cx({ [trim(join(split(this.props.pathname, '/'), ' section-'))]: this.props.pathname !== '/', siteroot: this.props.pathname === '/', [`is-adding-contenttype-${decodeURIComponent( this.props.location?.search?.startsWith('?type=') ? this.props.location?.search?.replace('?type=', '') : '', ) .replaceAll(' ', '-') .toLowerCase()}`]: this.props.location?.search?.startsWith('?type='), 'is-authenticated': !!this.props.token, 'is-anonymous': !this.props.token, 'cms-ui': isCmsUI, 'public-ui': !isCmsUI, })} /> <SkipLinks /> <Header pathname={path} /> <Breadcrumbs pathname={path} /> <MultilingualRedirector pathname={this.props.pathname} contentLanguage={this.props.content?.language?.token} > <Segment basic className="content-area" onClick={this.dispatchContentClick} > <main ref={this.mainRef}> <OutdatedBrowser /> {this.props.connectionRefused ? ( <ConnectionRefusedView staticContext={this.props.staticContext} /> ) : this.state.hasError ? ( <Error message={this.state.error.message} stackTrace={this.state.errorInfo.componentStack} /> ) : ( renderRoutes(this.props.route.routes, { staticContext: this.props.staticContext, }) )} </main> </Segment> </MultilingualRedirector> <RouteAnnouncer /> <Footer /> <LockingToastsFactory content={this.props.content} user={this.props.userId} /> <WorkingCopyToastsFactory content={this.props.content} /> <ToastContainer position={toast.POSITION.BOTTOM_CENTER} hideProgressBar transition={Slide} autoClose={5000} closeButton={ <Icon className="toast-dismiss-action" name={clearSVG} size="18px" /> } /> <AppExtras {...this.props} /> </PluggablesProvider> ); } } export const __test__ = connect( (state, props) => ({ pathname: props.location.pathname, token: state.userSession.token, content: state.content.data, apiError: state.apierror.error, connectionRefused: state.apierror.connectionRefused, }), {}, )(App); export const fetchContent = async ({ store, location }) => { const content = await store.dispatch( getContent(getBaseUrl(location.pathname)), ); const promises = []; const { blocksConfig } = config.blocks; const visitor = ([id, data]) => { const blockType = data['@type']; const block = blocksConfig[blockType]; if (!block) return; const { getAsyncData } = block; if (getAsyncData) { const p = getAsyncData({ store, dispatch: store.dispatch, path: location.pathname, location, id, data, blocksConfig, content, }); if (!p?.length) { throw new Error( 'You should return a list of promises from getAsyncData', ); } promises.push(...p); } }; visitBlocks(content, visitor); await Promise.allSettled(promises); return content; }; export function connectAppComponent(AppComponent) { return compose( asyncConnect([ { key: 'breadcrumbs', promise: ({ location, store: { dispatch } }) => { // Do not trigger the breadcrumbs action if the expander is present if ( __SERVER__ && !hasApiExpander('breadcrumbs', getBaseUrl(location.pathname)) ) { return dispatch(getBreadcrumbs(getBaseUrl(location.pathname))); } }, }, { key: 'content', promise: ({ location, store }) => __SERVER__ && fetchContent({ store, location }), }, { key: 'navigation', promise: ({ location, store: { dispatch } }) => { // Do not trigger the navigation action if the expander is present if ( __SERVER__ && !hasApiExpander('navigation', getBaseUrl(location.pathname)) ) { return dispatch( getNavigation( getBaseUrl(location.pathname), config.settings.navDepth, ), ); } }, }, { key: 'types', promise: ({ location, store: { dispatch } }) => { // Do not trigger the types action if the expander is present if ( __SERVER__ && !hasApiExpander('types', getBaseUrl(location.pathname)) ) { return dispatch(getTypes(getBaseUrl(location.pathname))); } }, }, { key: 'workflow', promise: ({ location, store: { dispatch } }) => __SERVER__ && dispatch(getWorkflow(getBaseUrl(location.pathname))), }, ]), injectIntl, connect( (state, props) => ({ pathname: props.location.pathname, token: state.userSession.token, userId: state.userSession.token ? jwtDecode(state.userSession.token).sub : '', content: state.content.data, apiError: state.apierror.error, connectionRefused: state.apierror.connectionRefused, }), null, ), )(AppComponent); } export default connectAppComponent(App);