From 07650fe1c55571ec628dbe2cf8394709f0c7ae2d Mon Sep 17 00:00:00 2001 From: alin Date: Mon, 22 Apr 2024 16:02:15 +0300 Subject: [PATCH 01/15] refactor: Move all customizations from volto-eea-website-policy --- .../components/manage/Display/Display.jsx | 306 ++++++++++ .../volto/components/manage/Display/Readme.md | 1 + .../volto/components/manage/Toolbar/More.jsx | 541 ++++++++++++++++++ .../components/manage/Workflow/README.txt | 1 + .../components/manage/Workflow/Workflow.jsx | 421 ++++++++++++++ .../manage/Workflow/Workflow.test.jsx | 81 +++ .../__snapshots__/Workflow.test.jsx.snap | 181 ++++++ .../volto/components/manage/Workflow/xxx | 246 ++++++++ 8 files changed, 1778 insertions(+) create mode 100644 src/customizations/volto/components/manage/Display/Display.jsx create mode 100644 src/customizations/volto/components/manage/Display/Readme.md create mode 100644 src/customizations/volto/components/manage/Toolbar/More.jsx create mode 100644 src/customizations/volto/components/manage/Workflow/README.txt create mode 100644 src/customizations/volto/components/manage/Workflow/Workflow.jsx create mode 100644 src/customizations/volto/components/manage/Workflow/Workflow.test.jsx create mode 100644 src/customizations/volto/components/manage/Workflow/__snapshots__/Workflow.test.jsx.snap create mode 100644 src/customizations/volto/components/manage/Workflow/xxx diff --git a/src/customizations/volto/components/manage/Display/Display.jsx b/src/customizations/volto/components/manage/Display/Display.jsx new file mode 100644 index 00000000..6d38c3c1 --- /dev/null +++ b/src/customizations/volto/components/manage/Display/Display.jsx @@ -0,0 +1,306 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; + +import jwtDecode from 'jwt-decode'; +import { + getSchema, + getUser, + updateContent, + getContent, +} from '@plone/volto/actions'; +import { getLayoutFieldname } from '@plone/volto/helpers'; +import { FormFieldWrapper, Icon } from '@plone/volto/components'; +import { defineMessages, injectIntl } from 'react-intl'; +import config from '@plone/volto/registry'; + +import downSVG from '@plone/volto/icons/down-key.svg'; +import upSVG from '@plone/volto/icons/up-key.svg'; +import checkSVG from '@plone/volto/icons/check.svg'; + +const messages = defineMessages({ + Viewmode: { + id: 'Viewmode', + defaultMessage: 'View', + }, +}); + +const Option = injectLazyLibs('reactSelect')((props) => { + const { Option } = props.reactSelect.components; + return ( + + ); +}); + +const DropdownIndicator = injectLazyLibs('reactSelect')((props) => { + const { DropdownIndicator } = props.reactSelect.components; + return ( + + {props.selectProps.menuIsOpen ? ( + + ) : ( + + )} + + ); +}); + +const selectTheme = (theme) => ({ + ...theme, + borderRadius: 0, + colors: { + ...theme.colors, + primary25: 'hotpink', + primary: '#b8c6c8', + }, +}); + +const customSelectStyles = { + control: (styles, state) => ({ + ...styles, + border: 'none', + borderBottom: '2px solid #b8c6c8', + boxShadow: 'none', + borderBottomStyle: state.menuIsOpen ? 'dotted' : 'solid', + }), + menu: (styles, state) => ({ + ...styles, + top: null, + marginTop: 0, + boxShadow: 'none', + borderBottom: '2px solid #b8c6c8', + }), + menuList: (styles, state) => ({ + ...styles, + maxHeight: '400px', + }), + indicatorSeparator: (styles) => ({ + ...styles, + width: null, + }), + valueContainer: (styles) => ({ + ...styles, + padding: 0, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: null, + minHeight: '50px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 12px', + color: state.isSelected + ? '#007bc1' + : state.isFocused + ? '#4a4a4a' + : 'inherit', + ':active': { + backgroundColor: null, + }, + span: { + flex: '0 0 auto', + }, + svg: { + flex: '0 0 auto', + }, + }), +}; + +/** + * Display container class. + * @class Display + * @extends Component + */ +class DisplaySelect extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + getSchema: PropTypes.func.isRequired, + updateContent: PropTypes.func.isRequired, + getContent: PropTypes.func.isRequired, + loaded: PropTypes.bool.isRequired, + pathname: PropTypes.string.isRequired, + layouts: PropTypes.arrayOf(PropTypes.string), + layout: PropTypes.string, + type: PropTypes.string.isRequired, + }; + + /** + * Default properties + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + layouts: [], + layout: '', + rolesWhoCanChangeLayout: [], + }; + + state = { + hasMatchingRole: false, + selectedOption: { + value: this.props.layout, + label: config.views.layoutViewsNamesMapping?.[this.props.layout] + ? this.props.intl.formatMessage({ + id: config.views.layoutViewsNamesMapping?.[this.props.layout], + defaultMessage: + config.views.layoutViewsNamesMapping?.[this.props.layout], + }) + : this.props.layout, + }, + }; + + componentDidMount() { + this.props.getSchema(this.props.type); + } + + UNSAFE_componentWillMount() { + if (!this.props.rolesWhoCanChangeLayout.length) { + this.props.rolesWhoCanChangeLayout.push( + ...(config?.settings?.eea?.rolesWhoCanChangeLayout || []), + ); + } + if (!this.props.layouts.length) { + this.props.getSchema(this.props.type); + } + if (Object.keys(this.props.user).length === 0) { + this.props.getUser(this.props.userId); + } else { + const hasMatchingRole = this.props.user.roles.some((role) => + this.props.rolesWhoCanChangeLayout.includes(role), + ); + if (hasMatchingRole !== this.state.hasMatchingRole) { + this.setState({ hasMatchingRole }); + } + } + } + + /** + * Component will receive props + * @method componentWillReceiveProps + * @param {Object} nextProps Next properties + * @returns {undefined} + */ + UNSAFE_componentWillReceiveProps(nextProps) { + if (nextProps.pathname !== this.props.pathname) { + this.props.getSchema(nextProps.type); + } + if (!this.props.loaded && nextProps.loaded) { + this.props.getContent(nextProps.pathname); + } + + if (Object.keys(nextProps.user).length !== 0) { + const hasMatchingRole = nextProps.user.roles.some((role) => + this.props.rolesWhoCanChangeLayout.includes(role), + ); + if (hasMatchingRole !== this.state.hasMatchingRole) { + this.setState({ hasMatchingRole }); + } + } + } + + /** + * On set layout handler + * @method setLayout + * @param {Object} event Event object + * @returns {undefined} + */ + setLayout = (selectedOption) => { + this.props.updateContent(this.props.pathname, { + layout: selectedOption.value, + }); + this.setState({ selectedOption }); + }; + + selectValue = (option) => ( + + {option.label} + + ); + + optionRenderer = (option) => ( + + {option.label} + + + ); + + render() { + if (!this.state.hasMatchingRole) { + return null; + } + const { selectedOption } = this.state; + const Select = this.props.reactSelect.default; + const layoutsNames = config.views.layoutViewsNamesMapping; + const layoutOptions = this.props.layouts + .filter( + (layout) => + Object.keys(config.views.contentTypesViews).includes(layout) || + Object.keys(config.views.layoutViews).includes(layout), + ) + .map((item) => ({ + value: item, + label: + this.props.intl.formatMessage({ + id: layoutsNames[item], + defaultMessage: layoutsNames[item], + }) || item, + })); + + return layoutOptions?.length > 1 ? ( + + getWorkflowOptions(transition)), + 'label', + ).concat(this.props.currentStateValue)} + styles={customSelectStyles} + theme={selectTheme} + components={{ + DropdownIndicator, + Placeholder, + Option, + SingleValue, + }} + onChange={this.transition} + value={ + this.props.content.review_state + ? this.props.currentStateValue + : { + label: this.props.intl.formatMessage( + messages.messageNoWorkflow, + ), + value: 'noworkflow', + } + } + isSearchable={false} + /> + + ); + } +} + +export default compose( + injectIntl, + injectLazyLibs(['reactSelect']), + withRouter, + connect( + (state, props) => ({ + loaded: state.workflow.transition.loaded, + content: state.content.data, + workflowLoaded: state.workflow.get?.loaded, + contentHistory: state.workflow.history, + transitions: state.workflow.transitions, + currentStateValue: getCurrentStateMapping(state.workflow.currentState), + editingProgressSteps: + state?.editingProgress?.editing?.loaded === true + ? state?.editingProgress?.result?.steps + : [], + }), + { getContent, getWorkflow, transitionWorkflow }, + ), +)(Workflow); diff --git a/src/customizations/volto/components/manage/Workflow/Workflow.test.jsx b/src/customizations/volto/components/manage/Workflow/Workflow.test.jsx new file mode 100644 index 00000000..8a48086c --- /dev/null +++ b/src/customizations/volto/components/manage/Workflow/Workflow.test.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import { Provider } from 'react-intl-redux'; +import { waitFor, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import config from '@plone/volto/registry'; + +import Workflow from './Workflow'; + +const mockStore = configureStore(); + +jest.mock('@plone/volto/helpers/Loadable/Loadable'); +beforeAll( + async () => + await require('@plone/volto/helpers/Loadable/Loadable').__setLoadables(), +); + +beforeEach(() => { + config.settings.workflowMapping = { + published: { value: 'published', color: '#007bc1' }, + publish: { value: 'publish', color: '#007bc1' }, + private: { value: 'private', color: '#ed4033' }, + pending: { value: 'pending', color: '#f6a808' }, + send_back: { value: 'private', color: '#ed4033' }, + retract: { value: 'private', color: '#ed4033' }, + submit: { value: 'review', color: '#f4e037' }, + }; +}); + +describe('Workflow', () => { + it('renders an empty workflow component', async () => { + const store = mockStore({ + workflow: { + currentState: { id: 'published', title: 'Published' }, + history: [], + transition: { loaded: true }, + transitions: [], + }, + intl: { + locale: 'en', + messages: {}, + }, + content: { data: { review_state: 'published' } }, + }); + const { container } = render( + + + + + , + ); + await waitFor(() => screen.getByText(/Published/)); + expect(container).toMatchSnapshot(); + }); + + it('renders a workflow component', async () => { + const store = mockStore({ + workflow: { + currentState: { id: 'private', title: 'Private' }, + history: [{ review_state: 'private' }], + transition: { loaded: true }, + transitions: [{ '@id': 'http://publish', title: 'Publish' }], + }, + intl: { + locale: 'en', + messages: {}, + }, + content: { data: { review_state: 'private' } }, + }); + + const { container } = render( + + + + + , + ); + await waitFor(() => screen.getByText('Private')); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/customizations/volto/components/manage/Workflow/__snapshots__/Workflow.test.jsx.snap b/src/customizations/volto/components/manage/Workflow/__snapshots__/Workflow.test.jsx.snap new file mode 100644 index 00000000..f688149c --- /dev/null +++ b/src/customizations/volto/components/manage/Workflow/__snapshots__/Workflow.test.jsx.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Workflow renders a workflow component 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ + Private +
+ +
+
+ + +
+
+ +
+
+
+
+
+
+`; + +exports[`Workflow renders an empty workflow component 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ + Published +
+ +
+
+ + +
+
+
+
+
+
+
+
+`; diff --git a/src/customizations/volto/components/manage/Workflow/xxx b/src/customizations/volto/components/manage/Workflow/xxx new file mode 100644 index 00000000..90996c5a --- /dev/null +++ b/src/customizations/volto/components/manage/Workflow/xxx @@ -0,0 +1,246 @@ +import { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { uniqBy } from 'lodash'; +import { toast } from 'react-toastify'; +import { defineMessages, useIntl } from 'react-intl'; + +import { FormFieldWrapper, Icon, Toast } from '@plone/volto/components'; +import { + flattenToAppURL, + getWorkflowOptions, + getCurrentStateMapping, +} from '@plone/volto/helpers'; +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; + +import { + getContent, + getWorkflow, + transitionWorkflow, +} from '@plone/volto/actions'; +import downSVG from '@plone/volto/icons/down-key.svg'; +import upSVG from '@plone/volto/icons/up-key.svg'; +import checkSVG from '@plone/volto/icons/check.svg'; + +const messages = defineMessages({ + messageUpdated: { + id: 'Workflow updated.', + defaultMessage: 'Workflow updated.', + }, + messageNoWorkflow: { + id: 'No workflow', + defaultMessage: 'No workflow', + }, + state: { + id: 'State', + defaultMessage: 'State', + }, +}); + +const SingleValue = injectLazyLibs('reactSelect')(({ children, ...props }) => { + const stateDecorator = { + marginRight: '10px', + display: 'inline-block', + backgroundColor: props.selectProps.value.color || null, + content: ' ', + height: '10px', + width: '10px', + borderRadius: '50%', + }; + const { SingleValue } = props.reactSelect.components; + return ( + + + {children} + + ); +}); + +const Option = injectLazyLibs('reactSelect')((props) => { + const stateDecorator = { + marginRight: '10px', + display: 'inline-block', + backgroundColor: + props.selectProps.value.value === props.data.value + ? props.selectProps.value.color + : null, + content: ' ', + height: '10px', + width: '10px', + borderRadius: '50%', + border: + props.selectProps.value.value !== props.data.value + ? `1px solid ${props.data.color}` + : null, + }; + + const { Option } = props['reactSelect'].components; + return ( + + ); +}); + +const DropdownIndicator = injectLazyLibs('reactSelect')((props) => { + const { DropdownIndicator } = props.reactSelect.components; + return ( + + {props.selectProps.menuIsOpen ? ( + + ) : ( + + )} + + ); +}); + +const selectTheme = (theme) => ({ + ...theme, + borderRadius: 0, + colors: { + ...theme.colors, + primary25: 'hotpink', + primary: '#b8c6c8', + }, +}); + +const customSelectStyles = { + control: (styles, state) => ({ + ...styles, + border: 'none', + borderBottom: '2px solid #b8c6c8', + boxShadow: 'none', + borderBottomStyle: state.menuIsOpen ? 'dotted' : 'solid', + }), + menu: (styles, state) => ({ + ...styles, + top: null, + marginTop: 0, + boxShadow: 'none', + borderBottom: '2px solid #b8c6c8', + }), + indicatorSeparator: (styles) => ({ + ...styles, + width: null, + }), + valueContainer: (styles) => ({ + ...styles, + padding: 0, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: null, + minHeight: '50px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 12px', + color: state.isSelected + ? '#007bc1' + : state.isFocused + ? '#4a4a4a' + : 'inherit', + ':active': { + backgroundColor: null, + }, + span: { + flex: '0 0 auto', + }, + svg: { + flex: '0 0 auto', + }, + }), +}; + +function useWorkflow() { + const history = useSelector((state) => state.workflow.history, shallowEqual); + const transitions = useSelector( + (state) => state.workflow.transitions, + shallowEqual, + ); + const loaded = useSelector((state) => state.workflow.transition.loaded); + const currentStateValue = useSelector( + (state) => getCurrentStateMapping(state.workflow.currentState), + shallowEqual, + ); + + return { loaded, history, transitions, currentStateValue }; +} + +const Workflow = (props) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const { loaded, transitions, currentStateValue } = useWorkflow(); + const content = useSelector((state) => state.content?.data, shallowEqual); + const { pathname } = props; + + useEffect(() => { + dispatch(getWorkflow(pathname)); + dispatch(getContent(pathname)); + }, [dispatch, pathname, loaded]); + + const transition = (selectedOption) => { + dispatch(transitionWorkflow(flattenToAppURL(selectedOption.url))); + toast.success( + , + ); + }; + + const { Placeholder } = props.reactSelect.components; + const Select = props.reactSelect.default; + + return ( + + - )} - - {this.state.manualLinkInput && isEmpty(items) && ( - - - - - )} - {!this.state.manualLinkInput && ( - - )} - - - ); - } -} - -const ObjectBrowserWidgetMode = (mode) => - compose( - injectIntl, - withObjectBrowser, - withRouter, - connect(null, { searchContent }), - )((props) => ); -export { ObjectBrowserWidgetMode }; -export default compose( - injectIntl, - withObjectBrowser, - withRouter, - connect(null, { searchContent }), -)(ObjectBrowserWidgetComponent); diff --git a/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx b/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx index 0a8d1a24..4ea475af 100644 --- a/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx +++ b/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx @@ -5,7 +5,7 @@ import { Provider } from 'react-intl-redux'; import configureStore from 'redux-mock-store'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import ObjectBrowserWidgetComponent from './ObjectBrowserWidget'; +import ObjectBrowserWidgetComponent from '@plone/volto/components/manage/Widgets/ObjectBrowserWidget'; import '@testing-library/jest-dom/extend-expect'; const mockStore = configureStore(); @@ -161,11 +161,9 @@ describe('ObjectBrowserWidgetComponent', () => { fireEvent.change(getByPlaceholderText('No items selected'), { target: { value: 'http://localhost:3000/Plone/test' }, }); - expect(container.querySelector('button.primary')).toBeInTheDocument(); - fireEvent.click(container.querySelector('button.primary')); - fireEvent.click(container.querySelector('button.action')); + fireEvent.click(container.querySelector('button.cancel')); expect(container).toBeTruthy(); }); diff --git a/src/customizations/volto/components/theme/Comments/Comments.jsx b/src/customizations/volto/components/theme/Comments/Comments.jsx index 58251352..3d75814f 100644 --- a/src/customizations/volto/components/theme/Comments/Comments.jsx +++ b/src/customizations/volto/components/theme/Comments/Comments.jsx @@ -33,8 +33,7 @@ const messages = defineMessages({ defaultMessage: 'Comments', }, commentDescription: { - id: - 'You can add a comment by filling out the form below. Plain text formatting.', + id: 'You can add a comment by filling out the form below. Plain text formatting.', defaultMessage: 'You can add a comment by filling out the form below. Plain text formatting.', }, diff --git a/src/customizations/volto/components/theme/ContactForm/ContactForm.jsx b/src/customizations/volto/components/theme/ContactForm/ContactForm.jsx index eeb61974..1f1b6f4a 100644 --- a/src/customizations/volto/components/theme/ContactForm/ContactForm.jsx +++ b/src/customizations/volto/components/theme/ContactForm/ContactForm.jsx @@ -12,7 +12,7 @@ export class ContactFormComponent extends Component { } render() { - const remoteUrl = config.settings.contactForm; + const remoteUrl = config.settings.contactForm || '/'; return (

diff --git a/src/customizations/volto/components/theme/ContactForm/__snapshots__/ContactForm.test.js.snap b/src/customizations/volto/components/theme/ContactForm/__snapshots__/ContactForm.test.js.snap index ef103bf0..91fc6ac0 100644 --- a/src/customizations/volto/components/theme/ContactForm/__snapshots__/ContactForm.test.js.snap +++ b/src/customizations/volto/components/theme/ContactForm/__snapshots__/ContactForm.test.js.snap @@ -10,10 +10,13 @@ exports[`Contact form renders a contact form 1`] = ` + > + / +

`; @@ -28,10 +31,13 @@ exports[`Contact form renders a contact form with error message 1`] = ` + > + / +

`; diff --git a/src/customizations/volto/components/theme/EventDetails/EventDetails.jsx b/src/customizations/volto/components/theme/EventDetails/EventDetails.jsx index 4cff18e1..f291740e 100644 --- a/src/customizations/volto/components/theme/EventDetails/EventDetails.jsx +++ b/src/customizations/volto/components/theme/EventDetails/EventDetails.jsx @@ -143,6 +143,7 @@ const EventDetails = ({ content, display_as = 'aside' }) => { className="ics-download" target="_blank" href={`${expandToBackendURL(content['@id'])}/ics_view`} + rel="noopener" > {intl.formatMessage(messages.downloadEvent)} diff --git a/src/index.js b/src/index.js index 93d56634..281a43ce 100644 --- a/src/index.js +++ b/src/index.js @@ -35,11 +35,7 @@ import { v4 as uuid } from 'uuid'; import * as eea from './config'; import React from 'react'; -const restrictedBlocks = [ - '__grid', // Grid/Teaser block (kitconcept) - 'imagesGrid', - 'teaser', -]; +const restrictedBlocks = ['imagesGrid', 'teaser']; /** * Customizes the variations of a tabs block by modifying their schema and semantic icons. @@ -129,8 +125,15 @@ const applyConfig = (config) => { config.settings.eea = { ...eea, ...(config.settings.eea || {}), + contentTypesWithoutHeaderImage: ['Image'], }; + //include site title in + if (!config.settings.siteTitleFormat) { + config.settings.siteTitleFormat = { includeSiteTitle: true }; + } else config.settings.siteTitleFormat.includeSiteTitle = true; + config.settings.titleAndSiteTitleSeparator = '|'; + // #160689 Redirect contact-form to contact-us config.settings.contactForm = '/contact'; @@ -185,11 +188,13 @@ const applyConfig = (config) => { //Apply the image position style for image and leadimage blocks if (config.blocks.blocksConfig.leadimage) { - config.blocks.blocksConfig.leadimage.schemaEnhancer = addStylingFieldsetSchemaEnhancerImagePosition; + config.blocks.blocksConfig.leadimage.schemaEnhancer = + addStylingFieldsetSchemaEnhancerImagePosition; } if (config.blocks.blocksConfig.image) { - config.blocks.blocksConfig.image.schemaEnhancer = addStylingFieldsetSchemaEnhancerImagePosition; + config.blocks.blocksConfig.image.schemaEnhancer = + addStylingFieldsetSchemaEnhancerImagePosition; } // Set Languages in nextcloud-video-block @@ -197,9 +202,8 @@ const applyConfig = (config) => { config?.blocks?.blocksConfig?.nextCloudVideo?.subtitlesLanguages && config?.settings?.eea?.languages?.length > 0 ) - config.blocks.blocksConfig.nextCloudVideo.subtitlesLanguages = config.settings.eea.languages.map( - (el) => [el.code, el.name], - ); + config.blocks.blocksConfig.nextCloudVideo.subtitlesLanguages = + config.settings.eea.languages.map((el) => [el.code, el.name]); // Enable Title block config.blocks.blocksConfig.title.restricted = false; @@ -224,7 +228,7 @@ const applyConfig = (config) => { }; config.views.errorViews = { ...config.views.errorViews, - '404': NotFound, + 404: NotFound, }; // Apply slate text block customization if (config.blocks.blocksConfig.slate) { @@ -467,19 +471,22 @@ const applyConfig = (config) => { // Group if (config.blocks.blocksConfig.group) { - config.blocks.blocksConfig.group.schemaEnhancer = addStylingFieldsetSchemaEnhancer; + config.blocks.blocksConfig.group.schemaEnhancer = + addStylingFieldsetSchemaEnhancer; } // Columns if (config.blocks.blocksConfig.columnsBlock) { config.blocks.blocksConfig.columnsBlock.mostUsed = true; - config.blocks.blocksConfig.columnsBlock.schemaEnhancer = addStylingFieldsetSchemaEnhancer; + config.blocks.blocksConfig.columnsBlock.schemaEnhancer = + addStylingFieldsetSchemaEnhancer; } // Listing if (config.blocks.blocksConfig.listing) { config.blocks.blocksConfig.listing.title = 'Listing (Content)'; - config.blocks.blocksConfig.listing.schemaEnhancer = addStylingFieldsetSchemaEnhancer; + config.blocks.blocksConfig.listing.schemaEnhancer = + addStylingFieldsetSchemaEnhancer; } // Block chooser diff --git a/src/middleware/ok.js b/src/middleware/ok.js index 0c9c23e6..20764f53 100644 --- a/src/middleware/ok.js +++ b/src/middleware/ok.js @@ -7,9 +7,11 @@ const ok = function (req, res, next) { res.send('ok'); }; -export default function (express) { +const okMiddleware = function (express) { const middleware = express.Router(); middleware.all(config?.settings?.okRoute || '/ok', ok); middleware.id = 'ok'; return middleware; -} +}; + +export default okMiddleware; diff --git a/src/middleware/voltoCustom.js b/src/middleware/voltoCustom.js index ac5e5c73..5fbf77a2 100644 --- a/src/middleware/voltoCustom.js +++ b/src/middleware/voltoCustom.js @@ -29,9 +29,11 @@ function voltoCustomMiddleware(req, res, next) { }); } -export default function (express) { +const registervoltoCustomMiddleware = function (express) { const middleware = express.Router(); middleware.all(['**/voltoCustom.css$'], voltoCustomMiddleware); middleware.id = 'voltoCustom.css'; return middleware; -} +}; + +export default registervoltoCustomMiddleware; diff --git a/src/slate.js b/src/slate.js index 0a0559f6..1bd99f26 100644 --- a/src/slate.js +++ b/src/slate.js @@ -150,8 +150,8 @@ export default function installSlate(config) { config = installCallout(config); try { - renderLinkElement = require('@eeacms/volto-anchors/helpers') - .renderLinkElement; + renderLinkElement = + require('@eeacms/volto-anchors/helpers').renderLinkElement; } catch {} installSlateToolbarButton({ @@ -190,14 +190,16 @@ export default function installSlate(config) { ); // Remove blockquote, italic, strikethrough slate button from toolbarButtons - config.settings.slate.toolbarButtons = config.settings.slate.toolbarButtons.filter( - (item) => !['blockquote', 'italic', 'strikethrough'].includes(item), - ); + config.settings.slate.toolbarButtons = + config.settings.slate.toolbarButtons.filter( + (item) => !['blockquote', 'italic', 'strikethrough'].includes(item), + ); // Remove blockquote, italic, strikethrough slate button from expandedToolbarButtons - config.settings.slate.expandedToolbarButtons = config.settings.slate.expandedToolbarButtons.filter( - (item) => !['blockquote', 'italic', 'strikethrough'].includes(item), - ); + config.settings.slate.expandedToolbarButtons = + config.settings.slate.expandedToolbarButtons.filter( + (item) => !['blockquote', 'italic', 'strikethrough'].includes(item), + ); // Remove 'underline' and 'italic' hotkeys config.settings.slate.hotkeys = Object.keys(config.settings.slate.hotkeys) From e671c834773f9091e1dafb8ec7a1cbea88e53ee2 Mon Sep 17 00:00:00 2001 From: eea-jenkins <eea-github@googlegroups.com> Date: Mon, 22 Apr 2024 19:04:11 +0200 Subject: [PATCH 04/15] style: Automated code fix --- src/customizations/volto/components/manage/Toolbar/More.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/customizations/volto/components/manage/Toolbar/More.jsx b/src/customizations/volto/components/manage/Toolbar/More.jsx index 23defd01..afe86a7c 100644 --- a/src/customizations/volto/components/manage/Toolbar/More.jsx +++ b/src/customizations/volto/components/manage/Toolbar/More.jsx @@ -82,8 +82,7 @@ const messages = defineMessages({ defaultMessage: 'Changes applied', }, workingCopyAppliedBy: { - id: - 'Made by {creator} on {date}. This is not a working copy anymore, but the main content.', + id: 'Made by {creator} on {date}. This is not a working copy anymore, but the main content.', defaultMessage: 'Made by {creator} on {date}. This is not a working copy anymore, but the main content.', }, From c1ec6964ac5854124b0fd5c768d05402f96f8d3e Mon Sep 17 00:00:00 2001 From: ichim-david <ichim.david@gmail.com> Date: Tue, 23 Apr 2024 16:33:26 +0300 Subject: [PATCH 05/15] fix(tests): for eea settings after introduction of contentTypesWithoutHeaderImage (#228) --- src/config.js | 2 ++ src/index.js | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.js b/src/config.js index b251fa96..d7de50a5 100644 --- a/src/config.js +++ b/src/config.js @@ -342,3 +342,5 @@ export const colors = [ '#F9F9F9', '#FFFFFF', ]; + +export const contentTypesWithoutHeaderImage = ['Image']; diff --git a/src/index.js b/src/index.js index 281a43ce..9e9df423 100644 --- a/src/index.js +++ b/src/index.js @@ -125,7 +125,6 @@ const applyConfig = (config) => { config.settings.eea = { ...eea, ...(config.settings.eea || {}), - contentTypesWithoutHeaderImage: ['Image'], }; //include site title in <title> From 9b9e0232fe6cd624c19dc87bc8152477f1ee83fd Mon Sep 17 00:00:00 2001 From: David Ichim <ichim.david@gmail.com> Date: Mon, 29 Apr 2024 20:48:16 +0300 Subject: [PATCH 06/15] change(draft-image): to remove image when published date is set to the future --- src/components/theme/DraftBackground/DraftBackground.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/theme/DraftBackground/DraftBackground.jsx b/src/components/theme/DraftBackground/DraftBackground.jsx index 5eddbcfa..bb9978d2 100644 --- a/src/components/theme/DraftBackground/DraftBackground.jsx +++ b/src/components/theme/DraftBackground/DraftBackground.jsx @@ -22,6 +22,15 @@ const checkIfPublished = (props) => { //case 1 : review_state published if (props?.review_state === 'published') return true; + // remove draft image if effective date is set and is in the future + const effectiveDate = props?.content?.effective; + if ( + effectiveDate !== 'None' && + new Date(effectiveDate).getTime() > new Date().getTime() + ) { + return true; + } + //case 2: review_state null, but parent is published eg:Image in published folder if ( !props?.review_state && From d8fd0dab10aceebf57cff5c1e777b795117b5a04 Mon Sep 17 00:00:00 2001 From: dobri1408 <50819975+dobri1408@users.noreply.github.com> Date: Thu, 9 May 2024 16:29:49 +0300 Subject: [PATCH 07/15] refactor: Disable data figure and plotly chart - refs #269278 --- .../theme/Comments/__snapshots__/Comments.test.jsx.snap | 4 ++-- src/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/customizations/volto/components/theme/Comments/__snapshots__/Comments.test.jsx.snap b/src/customizations/volto/components/theme/Comments/__snapshots__/Comments.test.jsx.snap index 2ffd4afd..43499865 100644 --- a/src/customizations/volto/components/theme/Comments/__snapshots__/Comments.test.jsx.snap +++ b/src/customizations/volto/components/theme/Comments/__snapshots__/Comments.test.jsx.snap @@ -142,7 +142,7 @@ exports[`Comments renders a comments component 1`] = ` <span title="2017-11-06T19:36:01" > - 6 years ago + 7 years ago </span> </span> </div> @@ -242,7 +242,7 @@ exports[`Comments renders a comments component 1`] = ` <span title="2017-11-06T19:36:01" > - 6 years ago + 7 years ago </span> </span> </div> diff --git a/src/index.js b/src/index.js index 9e9df423..049f8229 100644 --- a/src/index.js +++ b/src/index.js @@ -35,7 +35,7 @@ import { v4 as uuid } from 'uuid'; import * as eea from './config'; import React from 'react'; -const restrictedBlocks = ['imagesGrid', 'teaser']; +const restrictedBlocks = ['imagesGrid', 'teaser', 'dataFigure', 'plotly_chart']; /** * Customizes the variations of a tabs block by modifying their schema and semantic icons. From 3c44dd3938b14ec54b010a291b40fcf475388f0a Mon Sep 17 00:00:00 2001 From: ichim-david <ichim.david@gmail.com> Date: Fri, 10 May 2024 11:04:19 +0300 Subject: [PATCH 08/15] feat(linking): allow anchor linking in ObjectBrowserWidget (#231) * Added back ObjectBrowserWidget.jsx from 17.x.x branch - before any changes to the anchor adding logic * style: Automated code fix * feat(teaser): modified ObjectBrowserWidget and UniversalLink to show links with hashes for manual links * fix(tests): comment snapshot due to year change --------- Co-authored-by: eea-jenkins <eea-github@googlegroups.com> --- .../manage/UniversalLink/UniversalLink.jsx | 4 +- .../manage/Widgets/ObjectBrowserWidget.jsx | 446 ++++++++++++++++++ .../volto/components/manage/Widgets/README.md | 1 + 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.jsx create mode 100644 src/customizations/volto/components/manage/Widgets/README.md diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx index e764abeb..34b50b76 100644 --- a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx @@ -74,7 +74,9 @@ const UniversalLink = ({ const checkedURL = URLUtils.checkAndNormalizeUrl(url); - url = checkedURL.url; + // we can receive an item with a linkWithHash property set from ObjectBrowserWidget + // if so, we use that instead of the url prop + url = (item && item['linkWithHash']) || checkedURL.url; let tag = ( <Link to={flattenToAppURL(url)} diff --git a/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.jsx b/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.jsx new file mode 100644 index 00000000..6e66677f --- /dev/null +++ b/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.jsx @@ -0,0 +1,446 @@ +/** + * ObjectBrowserWidget component. + * @module components/manage/Widgets/ObjectBrowserWidget + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { compact, isArray, isEmpty, remove } from 'lodash'; +import { connect } from 'react-redux'; +import { Label, Popup, Button } from 'semantic-ui-react'; +import { + flattenToAppURL, + isInternalURL, + isUrl, + normalizeUrl, + removeProtocol, +} from '@plone/volto/helpers/Url/Url'; +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 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'; + +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, + }; + + /** + * Default properties + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + description: null, + required: false, + error: [], + value: [], + mode: 'multiple', + return: 'multiple', + initialPath: '', + allowExternals: false, + }; + + state = { + manualLinkInput: '', + validURL: false, + }; + + constructor(props) { + super(props); + this.selectedItemsRef = React.createRef(); + this.placeholderRef = React.createRef(); + } + renderLabel(item) { + // show linkWithHash if available, otherwise @id + const href = item['linkWithHash'] || item['@id']; + return ( + <Popup + key={flattenToAppURL(href)} + content={ + <div style={{ display: 'flex' }}> + {isInternalURL(href) ? ( + <Icon name={homeSVG} size="18px" /> + ) : ( + <Icon name={blankSVG} size="18px" /> + )} +   + {flattenToAppURL(href)} + </div> + } + trigger={ + <Label> + <div className="item-title">{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', + 'linkWithHash', // add linkWithHash to the allowed attributes + '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) { + return isUrl(url); + } else { + return isInternalURL(url); + } + }; + + /** + * Splits a URL into its link and hash components. + * @param {string} url - The URL to split. + * @returns {[string, string]} - An array containing the link and hash components of the URL. + */ + getHashAndLinkFromUrl = (url) => { + return url.split('#'); + }; + + onSubmitManualLink = () => { + if (this.validateManualLink(this.state.manualLinkInput)) { + if (isInternalURL(this.state.manualLinkInput)) { + const [link, hash] = this.getHashAndLinkFromUrl( + this.state.manualLinkInput, + ); + const relative_link = flattenToAppURL(link); + // convert it into an internal on if possible + this.props + .searchContent( + '/', + { + 'path.query': relative_link, + '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) { + // if there is a hash within the url, add it to the item as linkWithHash + if (hash) { + resp.items[0]['linkWithHash'] = `${relative_link}#${hash}`; + } + 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, + }); + }; + + 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} + 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 + 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); diff --git a/src/customizations/volto/components/manage/Widgets/README.md b/src/customizations/volto/components/manage/Widgets/README.md new file mode 100644 index 00000000..b52d0898 --- /dev/null +++ b/src/customizations/volto/components/manage/Widgets/README.md @@ -0,0 +1 @@ +Customized ObjectBrowserWidget to preserve anchor links in the manually pasted internal URL. From b62de0a8fd9c953210f4b371b5117d70f9e04040 Mon Sep 17 00:00:00 2001 From: David Ichim <ichim.david@gmail.com> Date: Fri, 10 May 2024 11:23:33 +0300 Subject: [PATCH 09/15] fix(draft_image): added fixes from 1.x.x branch --- src/components/theme/DraftBackground/DraftBackground.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/theme/DraftBackground/DraftBackground.jsx b/src/components/theme/DraftBackground/DraftBackground.jsx index bb9978d2..bc4f3f05 100644 --- a/src/components/theme/DraftBackground/DraftBackground.jsx +++ b/src/components/theme/DraftBackground/DraftBackground.jsx @@ -25,10 +25,11 @@ const checkIfPublished = (props) => { // remove draft image if effective date is set and is in the future const effectiveDate = props?.content?.effective; if ( + effectiveDate && effectiveDate !== 'None' && new Date(effectiveDate).getTime() > new Date().getTime() ) { - return true; + return false; } //case 2: review_state null, but parent is published eg:Image in published folder From 0cb430424281f0d0a78e2f9b1326b6bc4128a6f3 Mon Sep 17 00:00:00 2001 From: David Ichim <ichim.david@gmail.com> Date: Fri, 10 May 2024 11:26:25 +0300 Subject: [PATCH 10/15] change(draft_image): added comments to DraftBackground code --- .../theme/DraftBackground/DraftBackground.jsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/theme/DraftBackground/DraftBackground.jsx b/src/components/theme/DraftBackground/DraftBackground.jsx index bc4f3f05..963e4814 100644 --- a/src/components/theme/DraftBackground/DraftBackground.jsx +++ b/src/components/theme/DraftBackground/DraftBackground.jsx @@ -7,22 +7,36 @@ import { compose } from 'redux'; import { flattenToAppURL } from '@plone/volto/helpers'; /** - * @param {Object} props - * @returns + * Removes any trailing slashes from the given string. + * + * @param {string} str - The input string to remove trailing slashes from. + * @returns {string} The input string with any trailing slashes removed. */ - const removeTrailingSlash = (str) => { return str.replace(/\/+$/, ''); }; -const checkIfPublished = (props) => { +/** + * Checks if the current content is published. + * + * This function checks the review state and effective date of the current content + * to determine if it should be considered published. It handles various cases, + * such as when the review state is null, when the content has a parent, and when + * the effective date is in the future. + * + * @param {object} props - The props object containing information about the current content. + * @param {string} props.contentId - The ID of the current content. + * @param {string} props.pathname - The current URL pathname. + * @param {object} props.content - The content object. + * @param {string} props.review_state - The review state of the current content. + * @returns {boolean} - True if the content is considered published, false otherwise. + */ +export const checkIfPublished = (props) => { //case 0: the state is not for the current content-type eg: Go to /contents from a page if (props.contentId !== removeTrailingSlash(props.pathname)) return true; - //case 1 : review_state published - if (props?.review_state === 'published') return true; - - // remove draft image if effective date is set and is in the future + // set draft image if effective date is set and is in the future + // regardless of review_state const effectiveDate = props?.content?.effective; if ( effectiveDate && @@ -32,6 +46,9 @@ const checkIfPublished = (props) => { return false; } + //case 1 : review_state published + if (props?.review_state === 'published') return true; + //case 2: review_state null, but parent is published eg:Image in published folder if ( !props?.review_state && @@ -51,6 +68,7 @@ const checkIfPublished = (props) => { return true; return false; }; + const DraftBackground = (props) => { let draftClass = 'wf-state-is-draft'; if (checkIfPublished(props)) { From cb4c6e7eb08a49bc557fa56f39cf51ae1f9e230c Mon Sep 17 00:00:00 2001 From: ichim-david <ichim.david@gmail.com> Date: Fri, 10 May 2024 12:09:10 +0300 Subject: [PATCH 11/15] feat(draft_image): added unittests for draft background logic (#232) * Added back ObjectBrowserWidget.jsx from 17.x.x branch - before any changes to the anchor adding logic * style: Automated code fix * feat(teaser): modified ObjectBrowserWidget and UniversalLink to show links with hashes for manual links * change(draft-image): to remove image when published date is set to the future * change(draft-image): show draft image for items with publishing date in the future - this flips the return value of the previous commit after ticket feedback * feat(draft-image): added docstrings and unittests for checkIfPublished logic --------- Co-authored-by: eea-jenkins <eea-github@googlegroups.com> --- .../DraftBackground/DraftBackground.test.jsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/components/theme/DraftBackground/DraftBackground.test.jsx diff --git a/src/components/theme/DraftBackground/DraftBackground.test.jsx b/src/components/theme/DraftBackground/DraftBackground.test.jsx new file mode 100644 index 00000000..11d69ba4 --- /dev/null +++ b/src/components/theme/DraftBackground/DraftBackground.test.jsx @@ -0,0 +1,85 @@ +import { checkIfPublished } from './DraftBackground'; +describe('checkIfPublished', () => { + it('should return true if contentId does not match pathname', () => { + const props = { + contentId: '/page1', + pathname: '/page2', + }; + + expect(checkIfPublished(props)).toBe(true); + }); + + it('should return false if effective date is in the future', () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + const props = { + contentId: '/page1', + pathname: '/page1', + content: { + effective: futureDate.toISOString(), + }, + }; + expect(checkIfPublished(props)).toBe(false); + }); + + it('should return true if review_state is published', () => { + const props = { + contentId: '/page1', + pathname: '/page1', + review_state: 'published', + }; + expect(checkIfPublished(props)).toBe(true); + }); + + it('should return true if review_state is null and parent is published', () => { + const props = { + contentId: '/page1', + pathname: '/page1', + review_state: null, + content: { + parent: { + review_state: 'published', + }, + }, + }; + expect(checkIfPublished(props)).toBe(true); + }); + + it('should return true if review_state is null and parent is empty', () => { + const props = { + contentId: '/page1', + pathname: '/page1', + review_state: null, + content: { + parent: {}, + }, + }; + expect(checkIfPublished(props)).toBe(true); + }); + + it('should return true if review_state is null and parent review_state is null', () => { + const props = { + contentId: '/page1', + pathname: '/page1', + review_state: null, + content: { + parent: { + review_state: null, + }, + }, + }; + expect(checkIfPublished(props)).toBe(true); + }); + + it('should return false if review_state is not published and effective date is not in the future', () => { + const props = { + contentId: '/page1', + pathname: '/page1', + review_state: 'private', + content: { + effective: '2023-01-01T00:00:00Z', + }, + }; + expect(checkIfPublished(props)).toBe(false); + }); +}); From 34766b3ba3d215c813fffb76d05832c57baf575f Mon Sep 17 00:00:00 2001 From: EEA Jenkins <@users.noreply.github.com> Date: Fri, 10 May 2024 11:50:53 +0000 Subject: [PATCH 12/15] Automated release 1.33.3 --- CHANGELOG.md | 42 +++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e09506..25a1a961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,47 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -### [1.33.2](https://github.com/eea/volto-eea-website-theme/compare/1.33.1...1.33.2) - 13 April 2024 +### [1.33.3](https://github.com/eea/volto-eea-website-theme/compare/1.34.0...1.33.3) - 10 May 2024 + +#### :rocket: New Features + +- feat: Volto 17 support - refs #259049 [Teodor Voicu - [`79ce620`](https://github.com/eea/volto-eea-website-theme/commit/79ce6202469523c21462a86cb72b93016b10a2b6)] + +#### :bug: Bug Fixes + +- fix(draft_image): added fixes from 1.x.x branch [David Ichim - [`b62de0a`](https://github.com/eea/volto-eea-website-theme/commit/b62de0a8fd9c953210f4b371b5117d70f9e04040)] + +#### :nail_care: Enhancements + +- change(draft_image): added comments to DraftBackground code [David Ichim - [`0cb4304`](https://github.com/eea/volto-eea-website-theme/commit/0cb430424281f0d0a78e2f9b1326b6bc4128a6f3)] +- refactor: Disable data figure and plotly chart - refs #269278 [dobri1408 - [`d8fd0da`](https://github.com/eea/volto-eea-website-theme/commit/d8fd0dab10aceebf57cff5c1e777b795117b5a04)] +- change(draft-image): to remove image when published date is set to the future [David Ichim - [`9b9e023`](https://github.com/eea/volto-eea-website-theme/commit/9b9e0232fe6cd624c19dc87bc8152477f1ee83fd)] +- refactor: Move all customizations from volto-eea-website-policy [alin - [`07650fe`](https://github.com/eea/volto-eea-website-theme/commit/07650fe1c55571ec628dbe2cf8394709f0c7ae2d)] + +#### :house: Internal changes + +- style: Automated code fix [eea-jenkins - [`e671c83`](https://github.com/eea/volto-eea-website-theme/commit/e671c834773f9091e1dafb8ec7a1cbea88e53ee2)] +- style: Automated code fix [eea-jenkins - [`5156bb5`](https://github.com/eea/volto-eea-website-theme/commit/5156bb54b48f9731278ea860847a019fff10a84f)] + +### [1.34.0](https://github.com/eea/volto-eea-website-theme/compare/1.33.2...1.34.0) - 9 May 2024 + +#### :bug: Bug Fixes + +- fix: Make sure effectiveDate is not null/undefined [alin - [`43400bc`](https://github.com/eea/volto-eea-website-theme/commit/43400bcce422049d9d38f17c7cc29e88062da902)] +- fix: DraftBackground for effectiveDate in the future [alin - [`da7fa80`](https://github.com/eea/volto-eea-website-theme/commit/da7fa806e5d6edbb7b016f0356d5a886b75ba892)] + +#### :nail_care: Enhancements + +- refactor: Disable data figure and plotly chart - refs #269278 [dobri1408 - [`002ef00`](https://github.com/eea/volto-eea-website-theme/commit/002ef003ad872ea8dd5c74acf74a85ca1fd1992b)] +- change(draft-image): show draft image for items with publishing date in the future [David Ichim - [`59a3873`](https://github.com/eea/volto-eea-website-theme/commit/59a387364f40d8d66a747921ccff946e7f8814e1)] + +#### :hammer_and_wrench: Others + +- Release 1.34.0 [alin - [`92cc065`](https://github.com/eea/volto-eea-website-theme/commit/92cc065730f44412a04b2df7159c540d858f4607)] +- Revert "Release 1.40.0" [alin - [`c1a4f30`](https://github.com/eea/volto-eea-website-theme/commit/c1a4f3042a91ebb4a1d674914d3bccf68954c94f)] +- Revert "fix: DraftBackground for effectiveDate in the future" [alin - [`ed2ca9b`](https://github.com/eea/volto-eea-website-theme/commit/ed2ca9b5881c6991d82bb2a8d3f0fe8e29f1a6d7)] +- Release 1.40.0 [alin - [`210f833`](https://github.com/eea/volto-eea-website-theme/commit/210f83384b6401f7c9a0e08070d69dd1fed690b1)] +### [1.33.2](https://github.com/eea/volto-eea-website-theme/compare/1.33.1...1.33.2) - 16 April 2024 #### :bug: Bug Fixes diff --git a/package.json b/package.json index 6f3414b1..a4aa8a25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eeacms/volto-eea-website-theme", - "version": "1.33.2", + "version": "1.33.3", "description": "@eeacms/volto-eea-website-theme: Volto add-on", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", From 337b2ff2e87534b2c9fe07146b7ca96f1ab50895 Mon Sep 17 00:00:00 2001 From: Alin Voinea <contact@avoinea.com> Date: Fri, 10 May 2024 19:50:11 +0300 Subject: [PATCH 13/15] Cleanup customizations (#229) * refactor: Cleanup obsolete customizations - refs #264528 * refactor: Remove obsolete Sharing customizations * refactor: Cleanup obsolete ObjectBrowserWidget.test.jsx * Delete search customization as it was just a temporary fix * Remove AppExtras because the ignore property was introduced in volto 17 * update customization of Workflow --------- Co-authored-by: dobri1408 <50819975+dobri1408@users.noreply.github.com> Co-authored-by: dobri1408 <dobriceanionut1408@gmail.com> --- .../manage/Blocks/Search/hocs/withSearch.jsx | 477 ------- .../Contents/ContentsPropertiesModal.jsx | 229 ---- .../volto/components/manage/Form/Form.jsx | 811 ------------ .../components/manage/Form/Form.test.jsx | 1124 ----------------- .../components/manage/Form/ModalForm.jsx | 328 ----- .../components/manage/Sharing/Sharing.jsx | 531 -------- .../manage/Sharing/Sharing.test.jsx | 72 -- .../__snapshots__/Sharing.test.jsx.snap | 216 ---- .../Widgets/ObjectBrowserWidget.test.jsx | 191 --- .../components/manage/Workflow/Workflow.jsx | 381 +++--- .../volto/components/manage/Workflow/xxx | 246 ---- .../components/theme/AppExtras/AppExtras.jsx | 27 - 12 files changed, 142 insertions(+), 4491 deletions(-) delete mode 100644 src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx delete mode 100644 src/customizations/volto/components/manage/Contents/ContentsPropertiesModal.jsx delete mode 100644 src/customizations/volto/components/manage/Form/Form.jsx delete mode 100644 src/customizations/volto/components/manage/Form/Form.test.jsx delete mode 100644 src/customizations/volto/components/manage/Form/ModalForm.jsx delete mode 100644 src/customizations/volto/components/manage/Sharing/Sharing.jsx delete mode 100644 src/customizations/volto/components/manage/Sharing/Sharing.test.jsx delete mode 100644 src/customizations/volto/components/manage/Sharing/__snapshots__/Sharing.test.jsx.snap delete mode 100644 src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx delete mode 100644 src/customizations/volto/components/manage/Workflow/xxx delete mode 100644 src/customizations/volto/components/theme/AppExtras/AppExtras.jsx diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx b/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx deleted file mode 100644 index e022b390..00000000 --- a/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx +++ /dev/null @@ -1,477 +0,0 @@ -//this customization is used for fixing: in the search block, on edit sort on and reversed order doesn't work. -//See here volto pr: https://github.com/plone/volto/pull/5262 -import React from 'react'; -import { useSelector } from 'react-redux'; -import qs from 'query-string'; -import { useLocation, useHistory } from 'react-router-dom'; - -import { resolveExtension } from '@plone/volto/helpers/Extensions/withBlockExtensions'; -import config from '@plone/volto/registry'; -import { usePrevious } from '@plone/volto/helpers'; -import { isEqual } from 'lodash'; - -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component'; -} - -const SEARCH_ENDPOINT_FIELDS = [ - 'SearchableText', - 'b_size', - 'limit', - 'sort_on', - 'sort_order', -]; - -const PAQO = 'plone.app.querystring.operation'; - -/** - * Based on URL state, gets an initial internal state for the search - * - * @function getInitialState - * - */ -function getInitialState( - data, - facets, - urlSearchText, - id, - sortOnParam, - sortOrderParam, -) { - const { types: facetWidgetTypes } = - config.blocks.blocksConfig.search.extensions.facetWidgets; - const facetSettings = data?.facets || []; - - return { - query: [ - ...(data.query?.query || []), - ...(facetSettings || []) - .map((facet) => { - if (!facet?.field) return null; - - const { valueToQuery } = resolveExtension( - 'type', - facetWidgetTypes, - facet, - ); - - const name = facet.field.value; - const value = facets[name]; - - return valueToQuery({ value, facet }); - }) - .filter((f) => !!f), - ...(urlSearchText - ? [ - { - i: 'SearchableText', - o: 'plone.app.querystring.operation.string.contains', - v: urlSearchText, - }, - ] - : []), - ], - sort_on: sortOnParam || data.query?.sort_on, - sort_order: sortOrderParam || data.query?.sort_order, - b_size: data.query?.b_size, - limit: data.query?.limit, - block: id, - }; -} - -/** - * "Normalizes" the search state to something that's serializable - * (for querying) and used to compute data for the ListingBody - * - * @function normalizeState - * - */ -function normalizeState({ - query, // base query - facets, // facet values - id, // block id - searchText, // SearchableText - sortOn, - sortOrder, - facetSettings, // data.facets extracted from block data -}) { - const { types: facetWidgetTypes } = - config.blocks.blocksConfig.search.extensions.facetWidgets; - - // Here, we are removing the QueryString of the Listing ones, which is present in the Facet - // because we already initialize the facet with those values. - const configuredFacets = facetSettings - ? facetSettings.map((facet) => facet?.field?.value) - : []; - - let copyOfQuery = query.query ? [...query.query] : []; - - const queryWithoutFacet = copyOfQuery.filter((query) => { - return !configuredFacets.includes(query.i); - }); - - const params = { - query: [ - ...(queryWithoutFacet || []), - ...(facetSettings || []).map((facet) => { - if (!facet?.field) return null; - - const { valueToQuery } = resolveExtension( - 'type', - facetWidgetTypes, - facet, - ); - - const name = facet.field.value; - const value = facets[name]; - - return valueToQuery({ value, facet }); - }), - ].filter((o) => !!o), - sort_on: sortOn || query.sort_on, - sort_order: sortOrder || query.sort_order, - b_size: query.b_size, - limit: query.limit, - block: id, - }; - - // Note Ideally the searchtext functionality should be restructured as being just - // another facet. But right now it's the same. This means that if a searchText - // is provided, it will override the SearchableText facet. - // If there is no searchText, the SearchableText in the query remains in effect. - // TODO eventually the searchText should be a distinct facet from SearchableText, and - // the two conditions could be combined, in comparison to the current state, when - // one overrides the other. - if (searchText) { - params.query = params.query.reduce( - // Remove SearchableText from query - (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), - [], - ); - params.query.push({ - i: 'SearchableText', - o: 'plone.app.querystring.operation.string.contains', - v: searchText, - }); - } - - return params; -} - -const getSearchFields = (searchData) => { - return Object.assign( - {}, - ...SEARCH_ENDPOINT_FIELDS.map((k) => { - return searchData[k] ? { [k]: searchData[k] } : {}; - }), - searchData.query ? { query: serializeQuery(searchData['query']) } : {}, - ); -}; - -/** - * A hook that will mirror the search block state to a hash location - */ -const useHashState = () => { - const location = useLocation(); - const history = useHistory(); - - /** - * Required to maintain parameter compatibility. - With this we will maintain support for receiving hash (#) and search (?) type parameters. - */ - const oldState = React.useMemo(() => { - return { - ...qs.parse(location.search), - ...qs.parse(location.hash), - }; - }, [location.hash, location.search]); - - // This creates a shallow copy. Why is this needed? - const current = Object.assign( - {}, - ...Array.from(Object.keys(oldState)).map((k) => ({ [k]: oldState[k] })), - ); - - const setSearchData = React.useCallback( - (searchData) => { - const newParams = qs.parse(location.search); - - let changed = false; - - Object.keys(searchData) - .sort() - .forEach((k) => { - if (searchData[k]) { - newParams[k] = searchData[k]; - if (oldState[k] !== searchData[k]) { - changed = true; - } - } - }); - - if (changed) { - history.push({ - search: qs.stringify(newParams), - }); - } - }, - [history, oldState, location.search], - ); - - return [current, setSearchData]; -}; - -/** - * A hook to make it possible to switch disable mirroring the search block - * state to the window location. When using the internal state we "start from - * scratch", as it's intended to be used in the edit page. - */ -const useSearchBlockState = (uniqueId, isEditMode) => { - const [hashState, setHashState] = useHashState(); - const [internalState, setInternalState] = React.useState({}); - - return isEditMode - ? [internalState, setInternalState] - : [hashState, setHashState]; -}; - -// Simple compress/decompress the state in URL by replacing the lengthy string -const deserializeQuery = (q) => { - return JSON.parse(q)?.map((kvp) => ({ - ...kvp, - o: kvp.o.replace(/^paqo/, PAQO), - })); -}; -const serializeQuery = (q) => { - return JSON.stringify( - q?.map((kvp) => ({ ...kvp, o: kvp.o.replace(PAQO, 'paqo') })), - ); -}; - -const withSearch = (options) => (WrappedComponent) => { - const { inputDelay = 1000 } = options || {}; - - function WithSearch(props) { - const { data, id, editable = false } = props; - - const [locationSearchData, setLocationSearchData] = useSearchBlockState( - id, - editable, - ); - - // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 - // eslint-disable-next-line react-hooks/exhaustive-deps - const urlQuery = locationSearchData.query - ? deserializeQuery(locationSearchData.query) - : []; - const urlSearchText = - locationSearchData.SearchableText || - urlQuery.find(({ i }) => i === 'SearchableText')?.v || - ''; - - // TODO: refactor, should use only useLocationStateManager()!!! - const [searchText, setSearchText] = React.useState(urlSearchText); - // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 - // eslint-disable-next-line react-hooks/exhaustive-deps - const configuredFacets = - data.facets?.map((facet) => facet?.field?.value) || []; - - // Here we are getting the initial value of the facet if Listing Query contains the same criteria as - // facet. - const queryData = data?.query?.query - ? deserializeQuery(JSON.stringify(data?.query?.query)) - : []; - - let intializeFacetWithQueryValue = []; - - for (let value of configuredFacets) { - const queryString = queryData.find((item) => item.i === value); - if (queryString) { - intializeFacetWithQueryValue = [ - ...intializeFacetWithQueryValue, - { [queryString.i]: queryString.v }, - ]; - } - } - - const multiFacets = data.facets - ?.filter((facet) => facet?.multiple) - .map((facet) => facet?.field?.value); - const [facets, setFacets] = React.useState( - Object.assign( - {}, - ...urlQuery.map(({ i, v }) => ({ [i]: v })), - // TODO: the 'o' should be kept. This would be a major refactoring of the facets - ...intializeFacetWithQueryValue, - // support for simple filters like ?Subject=something - // TODO: since the move to hash params this is no longer working. - // We'd have to treat the location.search and manage it just like the - // hash, to support it. We can read it, but we'd have to reset it as - // well, so at that point what's the difference to the hash? - ...configuredFacets.map((f) => - locationSearchData[f] - ? { - [f]: - multiFacets.indexOf(f) > -1 - ? [locationSearchData[f]] - : locationSearchData[f], - } - : {}, - ), - ), - ); - const previousUrlQuery = usePrevious(urlQuery); - - // During first render the previousUrlQuery is undefined and urlQuery - // is empty so it ressetting the facet when you are navigating but during reload we have urlQuery and we need - // to set the facet at first render. - const preventOverrideOfFacetState = - previousUrlQuery === undefined && urlQuery.length === 0; - - React.useEffect(() => { - if ( - !isEqual(urlQuery, previousUrlQuery) && - !preventOverrideOfFacetState - ) { - setFacets( - Object.assign( - {}, - ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets - - // support for simple filters like ?Subject=something - // TODO: since the move to hash params this is no longer working. - // We'd have to treat the location.search and manage it just like the - // hash, to support it. We can read it, but we'd have to reset it as - // well, so at that point what's the difference to the hash? - ...configuredFacets.map((f) => - locationSearchData[f] - ? { - [f]: - multiFacets.indexOf(f) > -1 - ? [locationSearchData[f]] - : locationSearchData[f], - } - : {}, - ), - ), - ); - } - }, [ - urlQuery, - configuredFacets, - locationSearchData, - multiFacets, - previousUrlQuery, - preventOverrideOfFacetState, - ]); - - const [sortOn, setSortOn] = React.useState(data?.query?.sort_on); - const [sortOrder, setSortOrder] = React.useState(data?.query?.sort_order); - - const [searchData, setSearchData] = React.useState( - getInitialState(data, facets, urlSearchText, id), - ); - - const deepFacets = JSON.stringify(facets); - const deepData = JSON.stringify(data); - React.useEffect(() => { - setSearchData( - getInitialState( - JSON.parse(deepData), - JSON.parse(deepFacets), - urlSearchText, - id, - sortOn, - sortOrder, - ), - ); - }, [deepData, deepFacets, urlSearchText, id, sortOn, sortOrder]); - - const timeoutRef = React.useRef(); - const facetSettings = data?.facets; - - const deepQuery = JSON.stringify(data.query); - const onTriggerSearch = React.useCallback( - ( - toSearchText = undefined, - toSearchFacets = undefined, - toSortOn = undefined, - toSortOrder = undefined, - ) => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout( - () => { - const newSearchData = normalizeState({ - id, - query: data.query || {}, - facets: toSearchFacets || facets, - searchText: toSearchText ? toSearchText.trim() : '', - sortOn: toSortOn || undefined, - sortOrder: toSortOrder || sortOrder, - facetSettings, - }); - if (toSearchFacets) setFacets(toSearchFacets); - if (toSortOn) setSortOn(toSortOn || undefined); - if (toSortOrder) setSortOrder(toSortOrder); - setSearchData(newSearchData); - setLocationSearchData(getSearchFields(newSearchData)); - }, - toSearchFacets ? inputDelay / 3 : inputDelay, - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - // Use deep comparison of data.query - deepQuery, - facets, - id, - setLocationSearchData, - searchText, - sortOn, - sortOrder, - facetSettings, - ], - ); - - const removeSearchQuery = () => { - let newSearchData = { ...searchData }; - newSearchData.query = searchData.query.reduce( - // Remove SearchableText from query - (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), - [], - ); - setSearchData(newSearchData); - setLocationSearchData(getSearchFields(newSearchData)); - }; - - const querystringResults = useSelector( - (state) => state.querystringsearch.subrequests, - ); - const totalItems = - querystringResults[id]?.total || querystringResults[id]?.items?.length; - - return ( - <WrappedComponent - {...props} - searchData={searchData} - facets={facets} - setFacets={setFacets} - setSortOn={setSortOn} - setSortOrder={setSortOrder} - sortOn={sortOn} - sortOrder={sortOrder} - searchedText={urlSearchText} - searchText={searchText} - removeSearchQuery={removeSearchQuery} - setSearchText={setSearchText} - onTriggerSearch={onTriggerSearch} - totalItems={totalItems} - /> - ); - } - WithSearch.displayName = `WithSearch(${getDisplayName(WrappedComponent)})`; - - return WithSearch; -}; - -export default withSearch; diff --git a/src/customizations/volto/components/manage/Contents/ContentsPropertiesModal.jsx b/src/customizations/volto/components/manage/Contents/ContentsPropertiesModal.jsx deleted file mode 100644 index 5a153759..00000000 --- a/src/customizations/volto/components/manage/Contents/ContentsPropertiesModal.jsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Contents properties modal. - * @module components/manage/Contents/ContentsPropertiesModal - */ - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { isEmpty, map } from 'lodash'; -import { defineMessages, injectIntl } from 'react-intl'; - -import { updateContent } from '@plone/volto/actions'; -import { ModalForm } from '@plone/volto/components'; - -const messages = defineMessages({ - properties: { - id: 'Properties', - defaultMessage: 'Properties', - }, - default: { - id: 'Default', - defaultMessage: 'Default', - }, - effectiveTitle: { - id: 'Publishing Date', - defaultMessage: 'Publishing Date', - }, - effectiveDescription: { - id: 'If this date is in the future, the content will not show up in listings and searches until this date.', - defaultMessage: - 'If this date is in the future, the content will not show up in listings and searches until this date.', - }, - expiresTitle: { - id: 'Expiration Date', - defaultMessage: 'Expiration Date', - }, - expiresDescription: { - id: 'When this date is reached, the content will nolonger be visible in listings and searches.', - defaultMessage: - 'When this date is reached, the content will nolonger be visible in listings and searches.', - }, - rightsTitle: { - id: 'Rights', - defaultMessage: 'Rights', - }, - rightsDescription: { - id: 'Copyright statement or other rights information on this item.', - defaultMessage: - 'Copyright statement or other rights information on this item.', - }, - creatorsTitle: { - id: 'Creators', - defaultMessage: 'Creators', - }, - creatorsDescription: { - id: 'Persons responsible for creating the content of this item. Please enter a list of user names, one per line. The principal creator should come first.', - defaultMessage: - 'Persons responsible for creating the content of this item. Please enter a list of user names, one per line. The principal creator should come first.', - }, - excludeFromNavTitle: { - id: 'Exclude from navigation', - defaultMessage: 'Exclude from navigation', - }, - excludeFromNavDescription: { - id: 'If selected, this item will not appear in the navigation tree', - defaultMessage: - 'If selected, this item will not appear in the navigation tree', - }, - yes: { - id: 'Yes', - defaultMessage: 'Yes', - }, - no: { - id: 'No', - defaultMessage: 'No', - }, -}); - -/** - * ContentsPropertiesModal class. - * @class ContentsPropertiesModal - * @extends Component - */ -class ContentsPropertiesModal extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - updateContent: PropTypes.func.isRequired, - items: PropTypes.arrayOf(PropTypes.string).isRequired, - request: PropTypes.shape({ - loading: PropTypes.bool, - loaded: PropTypes.bool, - }).isRequired, - open: PropTypes.bool.isRequired, - onOk: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, - }; - - /** - * Constructor - * @method constructor - * @param {Object} props Component properties - * @constructs ContentsUploadModal - */ - constructor(props) { - super(props); - - this.onSubmit = this.onSubmit.bind(this); - } - - /** - * Component will receive props - * @method componentWillReceiveProps - * @param {Object} nextProps Next properties - * @returns {undefined} - */ - UNSAFE_componentWillReceiveProps(nextProps) { - if (this.props.request.loading && nextProps.request.loaded) { - this.props.onOk(); - } - } - - /** - * Submit handler - * @method onSubmit - * @param {Object} data Form data - * @returns {undefined} - */ - onSubmit(data) { - if (isEmpty(data)) { - this.props.onOk(); - } else { - this.props.updateContent( - this.props.items, - map(this.props.items, () => data), - ); - } - } - - /** - * Render method. - * @method render - * @returns {string} Markup for the component. - */ - render() { - return ( - this.props.open && ( - <ModalForm - open={this.props.open} - onSubmit={this.onSubmit} - onCancel={this.props.onCancel} - title={this.props.intl.formatMessage(messages.properties)} - schema={{ - fieldsets: [ - { - id: 'default', - title: this.props.intl.formatMessage(messages.default), - fields: [ - 'effective', - 'expires', - 'rights', - 'creators', - 'exclude_from_nav', - ], - }, - ], - properties: { - effective: { - description: this.props.intl.formatMessage( - messages.effectiveDescription, - ), - title: this.props.intl.formatMessage(messages.effectiveTitle), - type: 'string', - widget: 'datetime', - }, - expires: { - description: this.props.intl.formatMessage( - messages.expiresDescription, - ), - title: this.props.intl.formatMessage(messages.expiresTitle), - type: 'string', - widget: 'datetime', - }, - rights: { - description: this.props.intl.formatMessage( - messages.rightsDescription, - ), - title: this.props.intl.formatMessage(messages.rightsTitle), - type: 'string', - widget: 'textarea', - }, - creators: { - description: this.props.intl.formatMessage( - messages.creatorsDescription, - ), - title: this.props.intl.formatMessage(messages.creatorsTitle), - type: 'array', - }, - exclude_from_nav: { - description: this.props.intl.formatMessage( - messages.excludeFromNavDescription, - ), - title: this.props.intl.formatMessage( - messages.excludeFromNavTitle, - ), - type: 'boolean', - }, - }, - required: [], - }} - /> - ) - ); - } -} - -export default compose( - injectIntl, - connect( - (state) => ({ - request: state.content.update, - }), - { updateContent }, - ), -)(ContentsPropertiesModal); diff --git a/src/customizations/volto/components/manage/Form/Form.jsx b/src/customizations/volto/components/manage/Form/Form.jsx deleted file mode 100644 index 5ad420a7..00000000 --- a/src/customizations/volto/components/manage/Form/Form.jsx +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Form component. - * @module components/manage/Form/Form - */ - -//TODO: Remove this component from customizations. - -import { BlocksForm, Field, Icon, Toast } from '@plone/volto/components'; -import { - difference, - FormValidation, - getBlocksFieldname, - getBlocksLayoutFieldname, - messages, -} from '@plone/volto/helpers'; -import aheadSVG from '@plone/volto/icons/ahead.svg'; -import clearSVG from '@plone/volto/icons/clear.svg'; -import { - findIndex, - isEmpty, - keys, - map, - mapValues, - pickBy, - without, - cloneDeep, -} from 'lodash'; -import isBoolean from 'lodash/isBoolean'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { injectIntl } from 'react-intl'; -import { Portal } from 'react-portal'; -import { connect } from 'react-redux'; -import { - Button, - Container, - Form as UiForm, - Message, - Segment, - Tab, -} from 'semantic-ui-react'; -import { v4 as uuid } from 'uuid'; -import { toast } from 'react-toastify'; -import { BlocksToolbar, UndoToolbar } from '@plone/volto/components'; -import { setSidebarTab } from '@plone/volto/actions'; -import { compose } from 'redux'; -import config from '@plone/volto/registry'; - -/** - * Form container class. - * @class Form - * @extends Component - */ -class Form extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - schema: PropTypes.shape({ - fieldsets: PropTypes.arrayOf( - PropTypes.shape({ - fields: PropTypes.arrayOf(PropTypes.string), - id: PropTypes.string, - title: PropTypes.string, - }), - ), - properties: PropTypes.objectOf(PropTypes.any), - definitions: PropTypes.objectOf(PropTypes.any), - required: PropTypes.arrayOf(PropTypes.string), - }), - formData: PropTypes.objectOf(PropTypes.any), - pathname: PropTypes.string, - onSubmit: PropTypes.func, - onCancel: PropTypes.func, - submitLabel: PropTypes.string, - resetAfterSubmit: PropTypes.bool, - resetOnCancel: PropTypes.bool, - isEditForm: PropTypes.bool, - isAdminForm: PropTypes.bool, - title: PropTypes.string, - error: PropTypes.shape({ - message: PropTypes.string, - }), - loading: PropTypes.bool, - hideActions: PropTypes.bool, - description: PropTypes.string, - visual: PropTypes.bool, - blocks: PropTypes.arrayOf(PropTypes.object), - isFormSelected: PropTypes.bool, - onSelectForm: PropTypes.func, - editable: PropTypes.bool, - onChangeFormData: PropTypes.func, - requestError: PropTypes.string, - allowedBlocks: PropTypes.arrayOf(PropTypes.string), - showRestricted: PropTypes.bool, - }; - - /** - * Default properties. - * @property {Object} defaultProps Default properties. - * @static - */ - static defaultProps = { - formData: null, - onSubmit: null, - onCancel: null, - submitLabel: null, - resetAfterSubmit: false, - resetOnCancel: false, - isEditForm: false, - isAdminForm: false, - title: null, - description: null, - error: null, - loading: null, - hideActions: false, - visual: false, - blocks: [], - pathname: '', - schema: {}, - isFormSelected: true, - onSelectForm: null, - editable: true, - requestError: null, - allowedBlocks: null, - }; - - /** - * Constructor - * @method constructor - * @param {Object} props Component properties - * @constructs Form - */ - constructor(props) { - super(props); - const ids = { - title: uuid(), - text: uuid(), - }; - let { formData } = props; - // TODO Tiberiu: customized here - formData = formData || {}; // when coming from login screen, formData is null - // this fixes a bug where, if you go to an /edit page, it will show login (you need to wait the 5 seconds timeout), after login you get redirected back to the edit, then it crashes - // end customized - const blocksFieldname = getBlocksFieldname(formData); - const blocksLayoutFieldname = getBlocksLayoutFieldname(formData); - - if (!props.isEditForm) { - // It's a normal (add form), get defaults from schema - formData = { - ...mapValues(props.schema.properties, 'default'), - ...formData, - }; - } - // defaults for block editor; should be moved to schema on server side - // Adding fallback in case the fields are empty, so we are sure that the edit form - // shows at least the default blocks - if ( - formData.hasOwnProperty(blocksFieldname) && - formData.hasOwnProperty(blocksLayoutFieldname) - ) { - if ( - !formData[blocksLayoutFieldname] || - isEmpty(formData[blocksLayoutFieldname].items) - ) { - formData[blocksLayoutFieldname] = { - items: [ids.title, ids.text], - }; - } - if (!formData[blocksFieldname] || isEmpty(formData[blocksFieldname])) { - formData[blocksFieldname] = { - [ids.title]: { - '@type': 'title', - }, - [ids.text]: { - '@type': config.settings.defaultBlockType, - }, - }; - } - } - - let selectedBlock = null; - if ( - formData.hasOwnProperty(blocksLayoutFieldname) && - formData[blocksLayoutFieldname].items.length > 0 - ) { - if (config.blocks?.initialBlocksFocus === null) { - selectedBlock = null; - } else if (this.props.type in config.blocks?.initialBlocksFocus) { - // Default selected is not the first block, but the one from config. - // TODO Select first block and not an arbitrary one. - Object.keys(formData[blocksFieldname]).forEach((b_key) => { - if ( - formData[blocksFieldname][b_key]['@type'] === - config.blocks?.initialBlocksFocus?.[this.props.type] - ) { - selectedBlock = b_key; - } - }); - } else { - selectedBlock = formData[blocksLayoutFieldname].items[0]; - } - } - - this.state = { - formData, - initialFormData: cloneDeep(formData), - errors: {}, - selected: selectedBlock, - multiSelected: [], - isClient: false, - // Ensure focus remain in field after change - inFocus: {}, - }; - this.onChangeField = this.onChangeField.bind(this); - this.onSelectBlock = this.onSelectBlock.bind(this); - this.onSubmit = this.onSubmit.bind(this); - this.onCancel = this.onCancel.bind(this); - this.onTabChange = this.onTabChange.bind(this); - this.onBlurField = this.onBlurField.bind(this); - this.onClickInput = this.onClickInput.bind(this); - } - - /** - * On updates caused by props change - * if errors from Backend come, these will be shown to their corresponding Fields - * also the first Tab to have any errors will be selected - * @param {Object} prevProps - */ - async componentDidUpdate(prevProps, prevState) { - let { requestError } = this.props; - let errors = {}; - let activeIndex = 0; - - if (requestError && prevProps.requestError !== requestError) { - errors = - FormValidation.giveServerErrorsToCorrespondingFields(requestError); - activeIndex = FormValidation.showFirstTabWithErrors({ - errors, - schema: this.props.schema, - }); - - this.setState({ - errors, - activeIndex, - }); - } - - if (this.props.onChangeFormData) { - if ( - // TODO: use fast-deep-equal - JSON.stringify(prevState?.formData) !== - JSON.stringify(this.state.formData) - ) { - this.props.onChangeFormData(this.state.formData); - } - } - } - - /** - * Tab selection is done only by setting activeIndex in state - */ - onTabChange(e, { activeIndex }) { - const defaultFocus = this.props.schema.fieldsets[activeIndex].fields[0]; - this.setState({ - activeIndex, - ...(defaultFocus ? { inFocus: { [defaultFocus]: true } } : {}), - }); - } - - /** - * If user clicks on input, the form will be not considered pristine - * this will avoid onBlur effects without interraction with the form - * @param {Object} e event - */ - onClickInput(e) { - this.setState({ isFormPristine: false }); - } - - /** - * Validate fields on blur - * @method onBlurField - * @param {string} id Id of the field - * @param {*} value Value of the field - * @returns {undefined} - */ - onBlurField(id, value) { - if (!this.state.isFormPristine) { - const errors = FormValidation.validateFieldsPerFieldset({ - schema: this.props.schema, - formData: this.state.formData, - formatMessage: this.props.intl.formatMessage, - touchedField: { [id]: value }, - }); - - this.setState({ - errors, - }); - } - } - - /** - * Component did mount - * @method componentDidMount - * @returns {undefined} - */ - componentDidMount() { - this.setState({ isClient: true }); - } - - static getDerivedStateFromProps(props, state) { - let newState = { ...state }; - if (!props.isFormSelected) { - newState.selected = null; - } - - return newState; - } - - /** - * Change field handler - * Remove errors for changed field - * @method onChangeField - * @param {string} id Id of the field - * @param {*} value Value of the field - * @returns {undefined} - */ - onChangeField(id, value) { - this.setState((prevState) => { - const { errors, formData } = prevState; - delete errors[id]; - return { - errors, - formData: { - ...formData, - // We need to catch also when the value equals false this fixes #888 - [id]: - value || (value !== undefined && isBoolean(value)) ? value : null, - }, - // Changing the form data re-renders the select widget which causes the - // focus to get lost. To circumvent this, we set the focus back to - // the input. - // This could fix other widgets too but currently targeted - // against the select widget only. - // Ensure field to be in focus after the change - inFocus: { [id]: true }, - }; - }); - } - - /** - * Select block handler - * @method onSelectBlock - * @param {string} id Id of the field - * @param {string} isMultipleSelection true if multiple blocks are selected - * @returns {undefined} - */ - onSelectBlock(id, isMultipleSelection, event) { - let multiSelected = []; - let selected = id; - - if (isMultipleSelection) { - selected = null; - const blocksLayoutFieldname = getBlocksLayoutFieldname( - this.state.formData, - ); - - const blocks_layout = this.state.formData[blocksLayoutFieldname].items; - - if (event.shiftKey) { - const anchor = - this.state.multiSelected.length > 0 - ? blocks_layout.indexOf(this.state.multiSelected[0]) - : blocks_layout.indexOf(this.state.selected); - const focus = blocks_layout.indexOf(id); - - if (anchor === focus) { - multiSelected = [id]; - } else if (focus > anchor) { - multiSelected = [...blocks_layout.slice(anchor, focus + 1)]; - } else { - multiSelected = [...blocks_layout.slice(focus, anchor + 1)]; - } - } - - if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { - multiSelected = this.state.multiSelected || []; - if (!this.state.multiSelected.includes(this.state.selected)) { - multiSelected = [...multiSelected, this.state.selected]; - selected = null; - } - if (this.state.multiSelected.includes(id)) { - selected = null; - multiSelected = without(multiSelected, id); - } else { - multiSelected = [...multiSelected, id]; - } - } - } - - this.setState({ - selected, - multiSelected, - }); - - if (this.props.onSelectForm) { - if (event) event.nativeEvent.stopImmediatePropagation(); - this.props.onSelectForm(); - } - } - - /** - * Cancel handler - * It prevents event from triggering submit, reset form if props.resetAfterSubmit - * and calls this.props.onCancel - * @method onCancel - * @param {Object} event Event object. - * @returns {undefined} - */ - onCancel(event) { - if (event) { - event.preventDefault(); - } - if (this.props.resetOnCancel || this.props.resetAfterSubmit) { - this.setState({ - formData: this.props.formData, - }); - } - this.props.onCancel(event); - } - - /** - * Submit handler also validate form and collect errors - * @method onSubmit - * @param {Object} event Event object. - * @returns {undefined} - */ - onSubmit(event) { - if (event) { - event.preventDefault(); - } - - const errors = this.props.schema - ? FormValidation.validateFieldsPerFieldset({ - schema: this.props.schema, - formData: this.state.formData, - formatMessage: this.props.intl.formatMessage, - }) - : {}; - - if (keys(errors).length > 0) { - const activeIndex = FormValidation.showFirstTabWithErrors({ - errors, - schema: this.props.schema, - }); - this.setState( - { - errors, - activeIndex, - }, - () => { - Object.keys(errors).forEach((err) => - toast.error( - <Toast - error - title={this.props.schema.properties[err].title || err} - content={errors[err].join(', ')} - />, - ), - ); - }, - ); - // Changes the focus to the metadata tab in the sidebar if error - this.props.setSidebarTab(0); - } else { - // Get only the values that have been modified (Edit forms), send all in case that - // it's an add form - if (this.props.isEditForm) { - this.props.onSubmit(this.getOnlyFormModifiedValues()); - } else { - this.props.onSubmit(this.state.formData); - } - if (this.props.resetAfterSubmit) { - this.setState({ - formData: this.props.formData, - }); - } - } - } - - /** - * getOnlyFormModifiedValues handler - * It returns only the values of the fields that are have really changed since the - * form was loaded. Useful for edit forms and PATCH operations, when we only want to - * send the changed data. - * @method getOnlyFormModifiedValues - * @param {Object} event Event object. - * @returns {undefined} - */ - getOnlyFormModifiedValues = () => { - const fieldsModified = Object.keys( - difference(this.state.formData, this.state.initialFormData), - ); - return { - ...pickBy(this.state.formData, (value, key) => - fieldsModified.includes(key), - ), - ...(this.state.formData['@static_behaviors'] && { - '@static_behaviors': this.state.formData['@static_behaviors'], - }), - }; - }; - - /** - * Removed blocks and blocks_layout fields from the form. - * @method removeBlocksLayoutFields - * @param {object} schema The schema definition of the form. - * @returns A modified copy of the given schema. - */ - removeBlocksLayoutFields = (schema) => { - const newSchema = { ...schema }; - const layoutFieldsetIndex = findIndex( - newSchema.fieldsets, - (fieldset) => fieldset.id === 'layout', - ); - if (layoutFieldsetIndex > -1) { - const layoutFields = newSchema.fieldsets[layoutFieldsetIndex].fields; - newSchema.fieldsets[layoutFieldsetIndex].fields = layoutFields.filter( - (field) => field !== 'blocks' && field !== 'blocks_layout', - ); - if (newSchema.fieldsets[layoutFieldsetIndex].fields.length === 0) { - newSchema.fieldsets = [ - ...newSchema.fieldsets.slice(0, layoutFieldsetIndex), - ...newSchema.fieldsets.slice(layoutFieldsetIndex + 1), - ]; - } - } - return newSchema; - }; - - /** - * Render method. - * @method render - * @returns {string} Markup for the component. - */ - render() { - const { settings } = config; - const { schema: originalSchema, onCancel, onSubmit } = this.props; - const { formData } = this.state; - const schema = this.removeBlocksLayoutFields(originalSchema); - - return this.props.visual ? ( - // Removing this from SSR is important, since react-beautiful-dnd supports SSR, - // but draftJS don't like it much and the hydration gets messed up - this.state.isClient && ( - <div className="ui container"> - <BlocksToolbar - formData={this.state.formData} - selectedBlock={this.state.selected} - selectedBlocks={this.state.multiSelected} - onChangeBlocks={(newBlockData) => - this.setState({ - formData: { - ...formData, - ...newBlockData, - }, - }) - } - onSetSelectedBlocks={(blockIds) => - this.setState({ multiSelected: blockIds }) - } - onSelectBlock={this.onSelectBlock} - /> - <UndoToolbar - state={{ - formData: this.state.formData, - selected: this.state.selected, - multiSelected: this.state.multiSelected, - }} - enableHotKeys - onUndoRedo={({ state }) => this.setState(state)} - /> - <BlocksForm - onChangeFormData={(newFormData) => - this.setState({ - formData: { - ...formData, - ...newFormData, - }, - }) - } - onChangeField={this.onChangeField} - onSelectBlock={this.onSelectBlock} - properties={formData} - pathname={this.props.pathname} - selectedBlock={this.state.selected} - multiSelected={this.state.multiSelected} - manage={this.props.isAdminForm} - allowedBlocks={this.props.allowedBlocks} - showRestricted={this.props.showRestricted} - editable={this.props.editable} - isMainForm={this.props.editable} - /> - {this.state.isClient && this.props.editable && ( - <Portal - node={__CLIENT__ && document.getElementById('sidebar-metadata')} - > - <UiForm - method="post" - onSubmit={this.onSubmit} - error={keys(this.state.errors).length > 0} - > - {schema && - map(schema.fieldsets, (item) => [ - <Segment - secondary - attached - className={`fieldset-${item.id}`} - key={item.title} - > - {item.title} - </Segment>, - <Segment attached key={`fieldset-contents-${item.title}`}> - {map(item.fields, (field, index) => ( - <Field - {...schema.properties[field]} - id={field} - fieldSet={item.title.toLowerCase()} - formData={this.state.formData} - focus={this.state.inFocus[field]} - value={this.state.formData?.[field]} - required={schema.required.indexOf(field) !== -1} - onChange={this.onChangeField} - onBlur={this.onBlurField} - onClick={this.onClickInput} - key={field} - error={this.state.errors[field]} - /> - ))} - </Segment>, - ])} - </UiForm> - </Portal> - )} - </div> - ) - ) : ( - <Container> - <UiForm - method="post" - onSubmit={this.onSubmit} - error={keys(this.state.errors).length > 0} - className={settings.verticalFormTabs ? 'vertical-form' : ''} - > - <fieldset className="invisible"> - <Segment.Group raised> - {schema && schema.fieldsets.length > 1 && ( - <> - {settings.verticalFormTabs && this.props.title && ( - <Segment secondary attached key={this.props.title}> - {this.props.title} - </Segment> - )} - <Tab - menu={{ - secondary: true, - pointing: true, - attached: true, - tabular: true, - className: 'formtabs', - vertical: settings.verticalFormTabs, - }} - grid={{ paneWidth: 9, tabWidth: 3, stackable: true }} - onTabChange={this.onTabChange} - activeIndex={this.state.activeIndex} - panes={map(schema.fieldsets, (item) => ({ - menuItem: item.title, - render: () => [ - !settings.verticalFormTabs && this.props.title && ( - <Segment secondary attached key={this.props.title}> - {this.props.title} - </Segment> - ), - item.description && ( - <Message attached="bottom"> - {item.description} - </Message> - ), - ...map(item.fields, (field, index) => ( - <Field - {...schema.properties[field]} - isDisabled={!this.props.editable} - id={field} - formData={this.state.formData} - fieldSet={item.title.toLowerCase()} - focus={this.state.inFocus[field]} - value={this.state.formData?.[field]} - required={schema.required.indexOf(field) !== -1} - onChange={this.onChangeField} - onBlur={this.onBlurField} - onClick={this.onClickInput} - key={field} - error={this.state.errors[field]} - /> - )), - ], - }))} - /> - </> - )} - {schema && schema.fieldsets.length === 1 && ( - <Segment> - {this.props.title && ( - <Segment className="primary"> - <h1 style={{ fontSize: '16px' }}> {this.props.title}</h1> - </Segment> - )} - {this.props.description && ( - <Segment secondary>{this.props.description}</Segment> - )} - {keys(this.state.errors).length > 0 && ( - <Message - icon="warning" - negative - attached - header={this.props.intl.formatMessage(messages.error)} - content={this.props.intl.formatMessage( - messages.thereWereSomeErrors, - )} - /> - )} - {this.props.error && ( - <Message - icon="warning" - negative - attached - header={this.props.intl.formatMessage(messages.error)} - content={this.props.error.message} - /> - )} - {map(schema.fieldsets[0].fields, (field) => ( - <Field - {...schema.properties[field]} - id={field} - value={this.state.formData?.[field]} - required={schema.required.indexOf(field) !== -1} - onChange={this.onChangeField} - onBlur={this.onBlurField} - onClick={this.onClickInput} - key={field} - error={this.state.errors[field]} - /> - ))} - </Segment> - )} - {!this.props.hideActions && ( - <Segment className="actions" clearing> - {onSubmit && ( - <Button - basic - icon - primary - floated="right" - type="submit" - aria-label={ - this.props.submitLabel - ? this.props.submitLabel - : this.props.intl.formatMessage(messages.save) - } - title={ - this.props.submitLabel - ? this.props.submitLabel - : this.props.intl.formatMessage(messages.save) - } - loading={this.props.loading} - > - <Icon className="circled" name={aheadSVG} size="30px" /> - </Button> - )} - {onCancel && ( - <Button - basic - icon - secondary - aria-label={this.props.intl.formatMessage( - messages.cancel, - )} - title={this.props.intl.formatMessage(messages.cancel)} - floated="right" - onClick={this.onCancel} - > - <Icon className="circled" name={clearSVG} size="30px" /> - </Button> - )} - </Segment> - )} - </Segment.Group> - </fieldset> - </UiForm> - </Container> - ); - } -} - -const FormIntl = injectIntl(Form, { forwardRef: true }); - -export default compose( - connect(null, { setSidebarTab }, null, { forwardRef: true }), -)(FormIntl); diff --git a/src/customizations/volto/components/manage/Form/Form.test.jsx b/src/customizations/volto/components/manage/Form/Form.test.jsx deleted file mode 100644 index 36803d20..00000000 --- a/src/customizations/volto/components/manage/Form/Form.test.jsx +++ /dev/null @@ -1,1124 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { Provider } from 'react-intl-redux'; -import configureMockStore from 'redux-mock-store'; -import Form from './Form'; -import config from '@plone/volto/registry'; -import { FormValidation } from '@plone/volto/helpers'; -import '@testing-library/jest-dom/extend-expect'; - -const mockStore = configureMockStore(); -let store; - -jest.mock('@plone/volto/components/manage/Form/Field', () => (props) => { - return ( - <div id={`mocked-field-${props.id}`}> - {props.id} - {props.description} - <textarea - onClick={props.onClick} - onBlur={props.onBlur} - onChange={(target) => props.onChange(props.id, target?.target?.value)} - /> - </div> - ); -}); - -jest.mock( - '@plone/volto/components/manage/Form/BlocksToolbar', - () => (props) => { - return ( - <input - id={'blocks-toolbar'} - onClick={(target) => { - props.onSetSelectedBlocks( - target.target.value ? [...target.target.value.split(',')] : [], - ); - }} - onChange={props.onChangeBlocks} - onSelect={(target) => { - props.onSelectBlock( - target.target.id, - target.target.isMultipleSelection, - target.target.event, - ); - }} - /> - ); - }, -); - -jest.mock('@plone/volto/components/manage/Form/UndoToolbar', () => (props) => { - return <div>UndoToolbar</div>; -}); - -jest.mock( - '@plone/volto/components/manage/Blocks/Block/BlocksForm', - () => (props) => { - return <input id={'blocks-form'} onChange={props.onChangeFormData} />; - }, -); - -describe('Form', () => { - beforeEach(() => { - store = mockStore({ - intl: { - locale: 'en', - messages: {}, - }, - }); - }); - - it('renders "Test title" and has the correct structure without formData without crashing', () => { - config.blocks = { - initialBlocksFocus: { - typeB: 'typeB', - }, - }; - config.settings = { - verticalFormTabs: false, - }; - const props = { - isFormSelected: false, - schema: { - fieldsets: [{ fields: [], id: 'default', title: 'Default' }], - properties: {}, - definitions: {}, - required: [], - }, - formData: { - blocks: undefined, - blocks_layout: undefined, - }, - type: 'typeB', - title: 'Test title', - }; - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - expect(container).toBeTruthy(); - expect( - container.querySelector('.ui.form .invisible .ui.raised.segments'), - ).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - }); - - it('renders "Test title" and has the correct structure with formData without crashing with same types', () => { - config.blocks = { - initialBlocksFocus: { - typeB: 'typeB', - }, - }; - config.settings = { - verticalFormTabs: false, - }; - const props = { - schema: { - fieldsets: [{ fields: [], id: 'default', title: 'Default' }], - properties: {}, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - title: 'Test title', - }; - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - expect(container).toBeTruthy(); - expect( - container.querySelector('.ui.form .invisible .ui.raised.segments'), - ).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - }); - - it('renders "Test title" and has the correct structure with formData without crashing with different types and isEditForm true', () => { - config.blocks = { - initialBlocksFocus: { - typeA: 'typeA', - }, - }; - config.settings = { - verticalFormTabs: false, - }; - const props = { - schema: { - fieldsets: [{ fields: [], id: 'default', title: 'Default' }], - properties: {}, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - title: 'Test title', - isEditForm: true, - }; - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - expect(container).toBeTruthy(); - expect( - container.querySelector('.ui.form .invisible .ui.raised.segments'), - ).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - }); - - it('renders "Test title" and has the correct structure with formData without crashing with no focused block', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: false, - }; - const props = { - schema: { - fieldsets: [{ fields: [], id: 'default', title: 'Default' }], - properties: {}, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - title: 'Test title', - }; - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - expect(container).toBeTruthy(); - expect( - container.querySelector('.ui.form .invisible .ui.raised.segments'), - ).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - }); - - it('should display the correct fields on the currently selected fieldset', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: false, - }; - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - description: 'Fieldset 1 description', - }, - { - fields: ['field3', 'field4'], - id: 'fieldset2', - title: 'Fieldset 2', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - }, - field2: { title: 'Field 2' }, - field3: { title: 'Field 3' }, - field4: { title: 'Field 4' }, - }, - definitions: {}, - required: [], - }, - formData: { - testBlocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - TestBlocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - title: 'Test title', - description: 'Test description', - onChangeFormData: jest.fn(), - }; - - const prevProps = { requestError: null }; - const prevState = { formData: {}, errors: {}, activeIndex: 0 }; - const giveServerErrorsToCorrespondingFieldsMock = jest.spyOn( - FormValidation, - 'giveServerErrorsToCorrespondingFields', - ); - giveServerErrorsToCorrespondingFieldsMock.mockImplementation(() => [ - { message: 'Sample error message' }, - { message: 'Sample error message' }, - ]); - const requestError = 'Sample error message'; - - const { container, getByText, rerender } = render( - <Provider store={store}> - <Form - {...props} - requestError={prevProps.requestError} - formData={prevState.formData} - /> - </Provider>, - ); - - expect(getByText('Fieldset 1')).toBeInTheDocument(); - expect(getByText('Fieldset 2')).toBeInTheDocument(); - expect(getByText('Fieldset 1 description')).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field1')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field2')).toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field3'), - ).not.toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field4'), - ).not.toBeInTheDocument(); - - fireEvent.click(container.querySelector('#mocked-field-field2')); - fireEvent.click(getByText('Fieldset 2')); - - expect( - container.querySelector('#mocked-field-field1'), - ).not.toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field2'), - ).not.toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field3')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field4')).toBeInTheDocument(); - - rerender( - <Provider store={store}> - <Form - {...props} - requestError={requestError} - formData={props.formData} - errors={prevState.errors} - activeIndex={prevState.activeIndex} - /> - </Provider>, - ); - - expect(giveServerErrorsToCorrespondingFieldsMock).toHaveBeenCalledWith( - requestError, - ); - }); - - it('renders without crashing and selecting Submit/Cancel button with resetAfterSubmit and errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - { - fields: ['field3', 'field4'], - id: 'fieldset2', - title: 'Fieldset 2', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - resetAfterSubmit: true, - submitLabel: 'Submit', - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => ({ - field1: [], - field2: [], - })); - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - expect(getByText('Fieldset 1')).toBeInTheDocument(); - expect(getByText('Fieldset 2')).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field1')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field2')).toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field3'), - ).not.toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field4'), - ).not.toBeInTheDocument(); - - fireEvent.click(container.querySelector('button[aria-label="Submit"]')); - fireEvent.click(container.querySelector('button[aria-label="Cancel"]')); - }); - - it('renders without crashing and selecting Submit/Cancel button with resetAfterSubmit and isaEditForm and no errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - { - fields: ['field3', 'field4'], - id: 'fieldset2', - title: 'Fieldset 2', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - resetAfterSubmit: true, - isEditForm: true, - submitLabel: 'Submit', - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => ({})); - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - expect(getByText('Fieldset 1')).toBeInTheDocument(); - expect(getByText('Fieldset 2')).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field1')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field2')).toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field3'), - ).not.toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field4'), - ).not.toBeInTheDocument(); - - fireEvent.click(container.querySelector('button[aria-label="Submit"]')); - fireEvent.click(container.querySelector('button[aria-label="Cancel"]')); - }); - - it('renders without crashing and selecting Submit/Cancel button with no errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - { - fields: ['field3', 'field4'], - id: 'fieldset2', - title: 'Fieldset 2', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - submitLabel: 'Submit', - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => ({})); - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - expect(getByText('Fieldset 1')).toBeInTheDocument(); - expect(getByText('Fieldset 2')).toBeInTheDocument(); - expect(getByText('Test title')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field1')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field2')).toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field3'), - ).not.toBeInTheDocument(); - expect( - container.querySelector('#mocked-field-field4'), - ).not.toBeInTheDocument(); - - fireEvent.click(container.querySelector('button[aria-label="Submit"]')); - fireEvent.click(container.querySelector('button[aria-label="Cancel"]')); - }); - - it('renders only one fieldset and the actions to save/cancel', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container, getByText } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - expect(getByText('Error')).toBeInTheDocument(); - expect(getByText('Sample error message')).toBeInTheDocument(); - - expect(container.querySelector('#mocked-field-field1')).toBeInTheDocument(); - expect(container.querySelector('#mocked-field-field2')).toBeInTheDocument(); - - fireEvent.click(container.querySelector('#mocked-field-field2 textarea')); - fireEvent.blur(container.querySelector('#mocked-field-field2 textarea')); - fireEvent.change(container.querySelector('#mocked-field-field2 textarea'), { - target: { value: 'test change' }, - }); - }); - - it('triggers the onSubmit with the shiftKey and multiple selected blocks and multiple erorrs', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - visual: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - fireEvent.click(container.querySelector('#blocks-toolbar'), { - target: { value: 'id1,id2' }, - }); - fireEvent.change(container.querySelector('#blocks-toolbar'), { - target: { value: 'test change' }, - }); - fireEvent.select(container.querySelector('#blocks-toolbar'), { - target: { - id: 'id1', - isMultipleSelection: true, - event: { shiftKey: true }, - }, - }); - - fireEvent.change(container.querySelector('#blocks-form'), { - target: { value: 'test change' }, - }); - }); - - it('triggers the onSubmit with the shiftKey and multiple selected blocks when the selected blocks are in another order and multiple errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - id2: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1', 'id2'], - }, - }, - type: 'typeB', - isClient: true, - visual: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - fireEvent.click(container.querySelector('#blocks-toolbar'), { - target: { value: 'id2,id1' }, - }); - fireEvent.change(container.querySelector('#blocks-toolbar'), { - target: { value: 'test change' }, - }); - fireEvent.select(container.querySelector('#blocks-toolbar'), { - target: { - id: 'id1', - isMultipleSelection: true, - event: { shiftKey: true }, - }, - }); - }); - - it('triggers the onSubmit with the shiftKey and multiple selected blocks when there are no selected blocks and multiple errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - visual: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - fireEvent.click(container.querySelector('#blocks-toolbar'), { - target: { value: undefined }, - }); - fireEvent.change(container.querySelector('#blocks-toolbar'), { - target: { value: 'test change' }, - }); - fireEvent.select(container.querySelector('#blocks-toolbar'), { - target: { - id: 'id1', - isMultipleSelection: true, - event: { shiftKey: true }, - }, - }); - }); - - it('triggers the onSubmit with the ctrlKey and multiple selected blocks and multiple errors', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - visual: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - fireEvent.click(container.querySelector('#blocks-toolbar'), { - target: { value: 'id1,id2' }, - }); - fireEvent.change(container.querySelector('#blocks-toolbar'), { - target: { value: 'test change' }, - }); - fireEvent.select(container.querySelector('#blocks-toolbar'), { - target: { - id: 'id1', - isMultipleSelection: true, - event: { ctrlKey: true }, - }, - }); - }); - - it('triggers the onSubmit with the ctrlKey and multiple selected blocks and multiple errors when the selected block in not in the list of selected blocks', () => { - config.blocks = { - initialBlocksFocus: null, - }; - config.settings = { - verticalFormTabs: true, - }; - - const props = { - schema: { - fieldsets: [ - { - fields: ['field1', 'field2'], - id: 'fieldset1', - title: 'Fieldset 1', - }, - ], - properties: { - field1: { - title: 'Field 1', - description: 'Field 1 description', - items: ['field4'], - widget: 'textarea', - }, - field2: { title: 'Field 2', widget: 'textarea' }, - }, - definitions: {}, - required: [], - }, - formData: { - blocks: { - id1: { - '@type': 'typeB', - plaintext: 'Block A', - override_toc: false, - }, - }, - blocks_layout: { - items: ['id1'], - }, - }, - type: 'typeB', - isClient: true, - visual: true, - title: 'Test title', - description: 'Test description', - error: { - message: 'Sample error message', - }, - onSubmit: jest.fn(), - onCancel: jest.fn(), - }; - - const validateFieldsPerFieldsetMock = jest.spyOn( - FormValidation, - 'validateFieldsPerFieldset', - ); - validateFieldsPerFieldsetMock.mockImplementation(() => [ - 'field1', - 'field2', - ]); - - const { container } = render( - <Provider store={store}> - <Form {...props} /> - </Provider>, - ); - - fireEvent.click(container.querySelector('#blocks-toolbar'), { - target: { value: 'id1,id2' }, - }); - fireEvent.change(container.querySelector('#blocks-toolbar'), { - target: { value: 'test change' }, - }); - fireEvent.select(container.querySelector('#blocks-toolbar'), { - target: { - id: 'id3', - isMultipleSelection: true, - event: { ctrlKey: true }, - }, - }); - }); -}); diff --git a/src/customizations/volto/components/manage/Form/ModalForm.jsx b/src/customizations/volto/components/manage/Form/ModalForm.jsx deleted file mode 100644 index 505521ae..00000000 --- a/src/customizations/volto/components/manage/Form/ModalForm.jsx +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Modal form component. - * @module components/manage/Form/ModalForm - */ - -//TODO: Remove this component from customizations. - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { keys, map } from 'lodash'; -import { - Button, - Form as UiForm, - Header, - Menu, - Message, - Modal, - Dimmer, - Loader, -} from 'semantic-ui-react'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; -import { FormValidation } from '@plone/volto/helpers'; -import { Field, Icon } from '@plone/volto/components'; -import aheadSVG from '@plone/volto/icons/ahead.svg'; -import clearSVG from '@plone/volto/icons/clear.svg'; - -const messages = defineMessages({ - required: { - id: 'Required input is missing.', - defaultMessage: 'Required input is missing.', - }, - minLength: { - id: 'Minimum length is {len}.', - defaultMessage: 'Minimum length is {len}.', - }, - uniqueItems: { - id: 'Items must be unique.', - defaultMessage: 'Items must be unique.', - }, - save: { - id: 'Save', - defaultMessage: 'Save', - }, - cancel: { - id: 'Cancel', - defaultMessage: 'Cancel', - }, -}); - -/** - * Modal form container class. - * @class ModalForm - * @extends Component - */ -class ModalForm extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - schema: PropTypes.shape({ - fieldsets: PropTypes.arrayOf( - PropTypes.shape({ - fields: PropTypes.arrayOf(PropTypes.string), - id: PropTypes.string, - title: PropTypes.string, - }), - ), - properties: PropTypes.objectOf(PropTypes.any), - required: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - title: PropTypes.string.isRequired, - formData: PropTypes.objectOf(PropTypes.any), - submitError: PropTypes.string, - onSubmit: PropTypes.func.isRequired, - onCancel: PropTypes.func, - open: PropTypes.bool, - submitLabel: PropTypes.string, - loading: PropTypes.bool, - loadingMessage: PropTypes.string, - className: PropTypes.string, - }; - - /** - * Default properties. - * @property {Object} defaultProps Default properties. - * @static - */ - static defaultProps = { - submitLabel: null, - onCancel: null, - formData: {}, - open: true, - loading: null, - loadingMessage: null, - submitError: null, - className: null, - dimmer: null, - }; - - /** - * Constructor - * @method constructor - * @param {Object} props Component properties - * @constructs ModalForm - */ - constructor(props) { - super(props); - this.state = { - currentTab: 0, - errors: {}, - isFormPristine: true, - formData: props.formData, - }; - this.selectTab = this.selectTab.bind(this); - this.onChangeField = this.onChangeField.bind(this); - this.onBlurField = this.onBlurField.bind(this); - this.onClickInput = this.onClickInput.bind(this); - this.onSubmit = this.onSubmit.bind(this); - } - - /** - * Change field handler - * @method onChangeField - * @param {string} id Id of the field - * @param {*} value Value of the field - * @returns {undefined} - */ - onChangeField(id, value) { - this.setState({ - formData: { - ...this.state.formData, - [id]: value, - }, - }); - } - - /** - * If user clicks on input, the form will be not considered pristine - * this will avoid onBlur effects without interraction with the form - * @param {Object} e event - */ - onClickInput(e) { - this.setState({ isFormPristine: false }); - } - - /** - * Validate fields on blur - * @method onBlurField - * @param {string} id Id of the field - * @param {*} value Value of the field - * @returns {undefined} - */ - onBlurField(id, value) { - if (!this.state.isFormPristine) { - const errors = FormValidation.validateFieldsPerFieldset({ - schema: this.props.schema, - formData: this.state.formData, - formatMessage: this.props.intl.formatMessage, - touchedField: { [id]: value }, - }); - - this.setState({ - errors, - }); - } - } - - /** - * Submit handler - * @method onSubmit - * @param {Object} event Event object. - * @returns {undefined} - */ - onSubmit(event) { - event.preventDefault(); - const errors = FormValidation.validateFieldsPerFieldset({ - schema: this.props.schema, - formData: this.state.formData, - formatMessage: this.props.intl.formatMessage, - }); - - if (keys(errors).length > 0) { - this.setState({ - errors, - }); - } else { - let setFormDataCallback = (formData) => { - this.setState({ formData: formData, errors: {} }); - }; - this.props.onSubmit(this.state.formData, setFormDataCallback); - } - } - - /** - * Select tab handler - * @method selectTab - * @param {Object} event Event object. - * @param {number} index Selected tab index. - * @returns {undefined} - */ - selectTab(event, { index }) { - this.setState({ - currentTab: index, - }); - } - - /** - * Render method. - * @method render - * @returns {string} Markup for the component. - */ - render() { - const { schema, onCancel } = this.props; - const currentFieldset = schema.fieldsets[this.state.currentTab]; - - const fields = map(currentFieldset.fields, (field) => ({ - ...schema.properties[field], - id: field, - value: this.state.formData[field], - required: schema.required.indexOf(field) !== -1, - onChange: this.onChangeField, - onBlur: this.onBlurField, - onClick: this.onClickInput, - })); - - const state_errors = keys(this.state.errors).length > 0; - return ( - <Modal - dimmer={this.props.dimmer} - open={this.props.open} - className={this.props.className} - > - <Header>{this.props.title}</Header> - <Dimmer active={this.props.loading}> - <Loader> - {this.props.loadingMessage || ( - <FormattedMessage id="Loading" defaultMessage="Loading." /> - )} - </Loader> - </Dimmer> - <Modal.Content scrolling> - <UiForm - method="post" - onSubmit={this.onSubmit} - error={state_errors || Boolean(this.props.submitError)} - > - <Message error> - {state_errors ? ( - <FormattedMessage - id="There were some errors." - defaultMessage="There were some errors." - /> - ) : ( - '' - )} - <div>{this.props.submitError}</div> - </Message> - {schema.fieldsets.length > 1 && ( - <Menu tabular stackable> - {map(schema.fieldsets, (item, index) => ( - <Menu.Item - name={item.id} - index={index} - key={item.id} - active={this.state.currentTab === index} - onClick={this.selectTab} - > - {item.title} - </Menu.Item> - ))} - </Menu> - )} - {fields.map((field) => ( - <Field - {...field} - key={field.id} - onBlur={this.onBlurField} - onClick={this.onClickInput} - error={this.state.errors[field.id]} - /> - ))} - </UiForm> - </Modal.Content> - <Modal.Actions> - <Button - basic - icon - circular - primary - floated="right" - aria-label={ - this.props.submitLabel - ? this.props.submitLabel - : this.props.intl.formatMessage(messages.save) - } - title={ - this.props.submitLabel - ? this.props.submitLabel - : this.props.intl.formatMessage(messages.save) - } - onClick={this.onSubmit} - loading={this.props.loading} - > - <Icon name={aheadSVG} className="contents circled" size="30px" /> - </Button> - {onCancel && ( - <Button - basic - icon - circular - secondary - aria-label={this.props.intl.formatMessage(messages.cancel)} - title={this.props.intl.formatMessage(messages.cancel)} - floated="right" - onClick={onCancel} - > - <Icon name={clearSVG} className="circled" size="30px" /> - </Button> - )} - </Modal.Actions> - </Modal> - ); - } -} - -export default injectIntl(ModalForm); diff --git a/src/customizations/volto/components/manage/Sharing/Sharing.jsx b/src/customizations/volto/components/manage/Sharing/Sharing.jsx deleted file mode 100644 index 266f181d..00000000 --- a/src/customizations/volto/components/manage/Sharing/Sharing.jsx +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Sharing container. - * @module components/manage/Sharing/Sharing - */ - -//TODO: This component should be removed from customizations. - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Plug, Pluggable } from '@plone/volto/components/manage/Pluggable'; -import { Helmet } from '@plone/volto/helpers'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { Link, withRouter } from 'react-router-dom'; -import { find, isEqual, map } from 'lodash'; -import { Portal } from 'react-portal'; -import { - Button, - Checkbox, - Container as SemanticContainer, - Form, - Icon as IconOld, - Input, - Segment, - Table, -} from 'semantic-ui-react'; -import jwtDecode from 'jwt-decode'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; - -import { updateSharing, getSharing } from '@plone/volto/actions'; -import { getBaseUrl } from '@plone/volto/helpers'; -import { Icon, Toolbar, Toast } from '@plone/volto/components'; -import { toast } from 'react-toastify'; -import config from '@plone/volto/registry'; - -import aheadSVG from '@plone/volto/icons/ahead.svg'; -import clearSVG from '@plone/volto/icons/clear.svg'; -import backSVG from '@plone/volto/icons/back.svg'; - -const messages = defineMessages({ - searchForUserOrGroup: { - id: 'Search for user or group', - defaultMessage: 'Search for user or group', - }, - inherit: { - id: 'Inherit permissions from higher levels', - defaultMessage: 'Inherit permissions from higher levels', - }, - save: { - id: 'Save', - defaultMessage: 'Save', - }, - cancel: { - id: 'Cancel', - defaultMessage: 'Cancel', - }, - back: { - id: 'Back', - defaultMessage: 'Back', - }, - sharing: { - id: 'Sharing', - defaultMessage: 'Sharing', - }, - user: { - id: 'User', - defaultMessage: 'User', - }, - group: { - id: 'Group', - defaultMessage: 'Group', - }, - globalRole: { - id: 'Global role', - defaultMessage: 'Global role', - }, - inheritedValue: { - id: 'Inherited value', - defaultMessage: 'Inherited value', - }, - permissionsUpdated: { - id: 'Permissions updated', - defaultMessage: 'Permissions updated', - }, - permissionsUpdatedSuccessfully: { - id: 'Permissions have been updated successfully', - defaultMessage: 'Permissions have been updated successfully', - }, -}); - -/** - * SharingComponent class. - * @class SharingComponent - * @extends Component - */ -class SharingComponent extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - updateSharing: PropTypes.func.isRequired, - getSharing: PropTypes.func.isRequired, - updateRequest: PropTypes.shape({ - loading: PropTypes.bool, - loaded: PropTypes.bool, - }).isRequired, - pathname: PropTypes.string.isRequired, - entries: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string, - login: PropTypes.string, - roles: PropTypes.object, - title: PropTypes.string, - type: PropTypes.string, - }), - ).isRequired, - available_roles: PropTypes.arrayOf(PropTypes.object).isRequired, - inherit: PropTypes.bool, - title: PropTypes.string.isRequired, - login: PropTypes.string, - }; - - /** - * Default properties - * @property {Object} defaultProps Default properties. - * @static - */ - static defaultProps = { - inherit: null, - login: '', - }; - - /** - * Constructor - * @method constructor - * @param {Object} props Component properties - * @constructs Sharing - */ - constructor(props) { - super(props); - this.onCancel = this.onCancel.bind(this); - this.onChange = this.onChange.bind(this); - this.onChangeSearch = this.onChangeSearch.bind(this); - this.onSearch = this.onSearch.bind(this); - this.onSubmit = this.onSubmit.bind(this); - this.onToggleInherit = this.onToggleInherit.bind(this); - this.state = { - search: '', - isLoading: false, - inherit: props.inherit, - entries: props.entries, - isClient: false, - }; - } - - /** - * Component did mount - * @method componentDidMount - * @returns {undefined} - */ - componentDidMount() { - this.props.getSharing(getBaseUrl(this.props.pathname), this.state.search); - this.setState({ isClient: true }); - } - - /** - * Component will receive props - * @method componentWillReceiveProps - * @param {Object} nextProps Next properties - * @returns {undefined} - */ - UNSAFE_componentWillReceiveProps(nextProps) { - if (this.props.updateRequest.loading && nextProps.updateRequest.loaded) { - this.props.getSharing(getBaseUrl(this.props.pathname), this.state.search); - toast.success( - <Toast - success - title={this.props.intl.formatMessage(messages.permissionsUpdated)} - content={this.props.intl.formatMessage( - messages.permissionsUpdatedSuccessfully, - )} - />, - ); - } - this.setState({ - inherit: - this.props.inherit === null ? nextProps.inherit : this.state.inherit, - entries: map(nextProps.entries, (entry) => { - const values = find(this.state.entries, { id: entry.id }); - return { - ...entry, - roles: values ? values.roles : entry.roles, - }; - }), - }); - } - - /** - * Submit handler - * @method onSubmit - * @param {object} event Event object. - * @returns {undefined} - */ - onSubmit(event) { - const data = { entries: [] }; - event.preventDefault(); - if (this.props.inherit !== this.state.inherit) { - data.inherit = this.state.inherit; - } - for (let i = 0; i < this.props.entries.length; i += 1) { - if (!isEqual(this.props.entries[i].roles, this.state.entries[i].roles)) { - data.entries.push({ - id: this.state.entries[i].id, - type: this.state.entries[i].type, - roles: this.state.entries[i].roles, - }); - } - } - this.props.updateSharing(getBaseUrl(this.props.pathname), data); - } - - /** - * Search handler - * @method onSearch - * @param {object} event Event object. - * @returns {undefined} - */ - onSearch(event) { - event.preventDefault(); - this.setState({ isLoading: true }); - this.props - .getSharing(getBaseUrl(this.props.pathname), this.state.search) - .then(() => { - this.setState({ isLoading: false }); - }) - .catch((error) => { - this.setState({ isLoading: false }); - // eslint-disable-next-line no-console - console.error('Error searching users or groups', error); - }); - } - - /** - * On change search handler - * @method onChangeSearch - * @param {object} event Event object. - * @returns {undefined} - */ - onChangeSearch(event) { - this.setState({ - search: event.target.value, - }); - } - - /** - * On toggle inherit handler - * @method onToggleInherit - * @returns {undefined} - */ - onToggleInherit() { - this.setState((state) => ({ - inherit: !state.inherit, - })); - } - - /** - * On change handler - * @method onChange - * @param {object} event Event object - * @param {string} value Entry value - * @returns {undefined} - */ - onChange(event, { value }) { - const [principal, role] = value.split(':'); - this.setState({ - entries: map(this.state.entries, (entry) => ({ - ...entry, - roles: - entry.id === principal - ? { - ...entry.roles, - [role]: !entry.roles[role], - } - : entry.roles, - })), - }); - } - - /** - * Cancel handler - * @method onCancel - * @returns {undefined} - */ - onCancel() { - this.props.history.push(getBaseUrl(this.props.pathname)); - } - - /** - * Render method. - * @method render - * @returns {string} Markup for the component. - */ - render() { - const Container = - config.getComponent({ name: 'Container' }).component || SemanticContainer; - - return ( - <Container id="page-sharing"> - <Helmet title={this.props.intl.formatMessage(messages.sharing)} /> - <Segment.Group raised> - <Pluggable - name="sharing-component" - params={{ isLoading: this.state.isLoading }} - /> - <Plug pluggable="sharing-component" id="sharing-component-title"> - <Segment className="primary"> - <FormattedMessage - id="Sharing for {title}" - defaultMessage="Sharing for {title}" - values={{ title: <q>{this.props.title}</q> }} - /> - </Segment> - </Plug> - <Plug - pluggable="sharing-component" - id="sharing-component-description" - > - <Segment secondary> - <FormattedMessage - id="You can control who can view and edit your item using the list below." - defaultMessage="You can control who can view and edit your item using the list below." - /> - </Segment> - </Plug> - <Plug pluggable="sharing-component" id="sharing-component-search"> - {({ isLoading }) => { - return ( - <Segment> - <Form onSubmit={this.onSearch}> - <Form.Field> - <Input - name="SearchableText" - action={{ - icon: 'search', - loading: isLoading, - disabled: isLoading, - }} - placeholder={this.props.intl.formatMessage( - messages.searchForUserOrGroup, - )} - onChange={this.onChangeSearch} - id="sharing-component-search" - /> - </Form.Field> - </Form> - </Segment> - ); - }} - </Plug> - <Plug - pluggable="sharing-component" - id="sharing-component-form" - dependencies={[this.state.entries, this.props.available_roles]} - > - <Form onSubmit={this.onSubmit}> - <Table celled padded striped attached> - <Table.Header> - <Table.Row> - <Table.HeaderCell> - <FormattedMessage id="Name" defaultMessage="Name" /> - </Table.HeaderCell> - {this.props.available_roles?.map((role) => ( - <Table.HeaderCell key={role.id}> - {role.title} - </Table.HeaderCell> - ))} - </Table.Row> - </Table.Header> - <Table.Body> - {this.state.entries?.map((entry) => ( - <Table.Row key={entry.id}> - <Table.Cell> - <IconOld - name={entry.type === 'user' ? 'user' : 'users'} - title={ - entry.type === 'user' - ? this.props.intl.formatMessage(messages.user) - : this.props.intl.formatMessage(messages.group) - } - />{' '} - {entry.title} - {entry.login && ` (${entry.login})`} - </Table.Cell> - {this.props.available_roles?.map((role) => ( - <Table.Cell key={role.id}> - {entry.roles[role.id] === 'global' && ( - <IconOld - name="check circle outline" - title={this.props.intl.formatMessage( - messages.globalRole, - )} - color="blue" - /> - )} - {entry.roles[role.id] === 'acquired' && ( - <IconOld - name="check circle outline" - color="green" - title={this.props.intl.formatMessage( - messages.inheritedValue, - )} - /> - )} - {typeof entry.roles[role.id] === 'boolean' && ( - <Checkbox - onChange={this.onChange} - value={`${entry.id}:${role.id}`} - checked={entry.roles[role.id]} - disabled={entry.login === this.props.login} - /> - )} - </Table.Cell> - ))} - </Table.Row> - ))} - </Table.Body> - </Table> - <Segment attached> - <Form.Field> - <Checkbox - id="inherit-permissions-checkbox" - name="inherit-permissions-checkbox" - defaultChecked={this.state.inherit} - onChange={this.onToggleInherit} - label={ - <label htmlFor="inherit-permissions-checkbox"> - {this.props.intl.formatMessage(messages.inherit)} - </label> - } - /> - </Form.Field> - <p className="help"> - <FormattedMessage - id="By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, the symbol {inherited} indicates an inherited value. Similarly, the symbol {global} indicates a global role, which is managed by the site administrator." - defaultMessage="By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, the symbol {inherited} indicates an inherited value. Similarly, the symbol {global} indicates a global role, which is managed by the site administrator." - values={{ - inherited: ( - <IconOld name="check circle outline" color="green" /> - ), - global: ( - <IconOld name="check circle outline" color="blue" /> - ), - }} - /> - </p> - </Segment> - <Segment className="actions" attached clearing> - <Button - basic - icon - primary - floated="right" - type="submit" - aria-label={this.props.intl.formatMessage(messages.save)} - title={this.props.intl.formatMessage(messages.save)} - loading={this.props.updateRequest.loading} - onClick={this.onSubmit} - > - <Icon className="circled" name={aheadSVG} size="30px" /> - </Button> - <Button - basic - icon - secondary - aria-label={this.props.intl.formatMessage(messages.cancel)} - title={this.props.intl.formatMessage(messages.cancel)} - floated="right" - onClick={this.onCancel} - > - <Icon className="circled" name={clearSVG} size="30px" /> - </Button> - </Segment> - </Form> - </Plug> - </Segment.Group> - {this.state.isClient && ( - <Portal node={document.getElementById('toolbar')}> - <Toolbar - pathname={this.props.pathname} - hideDefaultViewButtons - inner={ - <Link - to={`${getBaseUrl(this.props.pathname)}`} - className="item" - > - <Icon - name={backSVG} - className="contents circled" - size="30px" - title={this.props.intl.formatMessage(messages.back)} - /> - </Link> - } - /> - </Portal> - )} - </Container> - ); - } -} - -export default compose( - withRouter, - injectIntl, - connect( - (state, props) => ({ - entries: state.sharing.data.entries, - inherit: state.sharing.data.inherit, - available_roles: state.sharing.data.available_roles, - updateRequest: state.sharing.update, - pathname: props.location.pathname, - title: state.content.data.title, - login: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', - }), - { updateSharing, getSharing }, - ), -)(SharingComponent); diff --git a/src/customizations/volto/components/manage/Sharing/Sharing.test.jsx b/src/customizations/volto/components/manage/Sharing/Sharing.test.jsx deleted file mode 100644 index 9e0cafbf..00000000 --- a/src/customizations/volto/components/manage/Sharing/Sharing.test.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import configureStore from 'redux-mock-store'; -import { Provider } from 'react-intl-redux'; -import jwt from 'jsonwebtoken'; -import { MemoryRouter } from 'react-router-dom'; -import { PluggablesProvider } from '@plone/volto/components/manage/Pluggable'; - -import Sharing from './Sharing'; - -const mockStore = configureStore(); - -jest.mock('react-portal', () => ({ - Portal: jest.fn(() => <div id="Portal" />), -})); - -describe('Sharing', () => { - it('renders a sharing component', () => { - const store = mockStore({ - userSession: { - token: jwt.sign({ sub: 'john-doe' }, 'secret'), - }, - sharing: { - data: { - entries: [ - { - id: 'john-doe', - disabled: false, - login: 'john-doe', - roles: { - Contributer: true, - }, - title: 'John Doe', - type: 'user', - }, - ], - inherit: true, - available_roles: [ - { - id: 'Contributor', - title: 'Can add', - }, - ], - }, - update: { - loading: false, - loaded: true, - }, - }, - content: { - data: { - title: 'Blog', - }, - }, - intl: { - locale: 'en', - messages: {}, - }, - }); - const component = renderer.create( - <Provider store={store}> - <PluggablesProvider> - <MemoryRouter> - <Sharing location={{ pathname: '/blog' }} /> - </MemoryRouter> - </PluggablesProvider> - </Provider>, - ); - const json = component.toJSON(); - expect(json).toMatchSnapshot(); - }); -}); diff --git a/src/customizations/volto/components/manage/Sharing/__snapshots__/Sharing.test.jsx.snap b/src/customizations/volto/components/manage/Sharing/__snapshots__/Sharing.test.jsx.snap deleted file mode 100644 index d1a1bcd4..00000000 --- a/src/customizations/volto/components/manage/Sharing/__snapshots__/Sharing.test.jsx.snap +++ /dev/null @@ -1,216 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Sharing renders a sharing component 1`] = ` -<div - className="ui container" - id="page-sharing" -> - <div - className="ui raised segments" - > - <div - className="ui segment primary" - > - Sharing for - <q> - Blog - </q> - </div> - <div - className="ui secondary segment" - > - You can control who can view and edit your item using the list below. - </div> - <div - className="ui segment" - > - <form - className="ui form" - onSubmit={[Function]} - > - <div - className="field" - > - <div - className="ui action input" - > - <input - id="sharing-component-search" - name="SearchableText" - onChange={[Function]} - placeholder="Search for user or group" - type="text" - /> - <button - className="ui icon button" - onClick={[Function]} - > - <i - aria-hidden="true" - className="search icon" - onClick={[Function]} - /> - </button> - </div> - </div> - </form> - </div> - <form - className="ui form" - onSubmit={[Function]} - > - <table - className="ui celled striped attached padded table" - > - <thead - className="" - > - <tr - className="" - > - <th - className="" - > - Name - </th> - <th - className="" - > - Can add - </th> - </tr> - </thead> - <tbody - className="" - > - <tr - className="" - > - <td - className="" - > - <i - aria-hidden="true" - className="user icon" - onClick={[Function]} - title="User" - /> - - John Doe - (john-doe) - </td> - <td - className="" - /> - </tr> - </tbody> - </table> - <div - className="ui attached segment" - > - <div - className="field" - > - <div - className="ui checked checkbox" - onChange={[Function]} - onClick={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - > - <input - checked={true} - className="hidden" - id="inherit-permissions-checkbox" - name="inherit-permissions-checkbox" - readOnly={true} - tabIndex={0} - type="checkbox" - /> - <label - htmlFor="inherit-permissions-checkbox" - > - Inherit permissions from higher levels - </label> - </div> - </div> - <p - className="help" - > - By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, the symbol - <i - aria-hidden="true" - className="green check circle outline icon" - onClick={[Function]} - /> - indicates an inherited value. Similarly, the symbol - <i - aria-hidden="true" - className="blue check circle outline icon" - onClick={[Function]} - /> - indicates a global role, which is managed by the site administrator. - </p> - </div> - <div - className="ui clearing attached segment actions" - > - <button - aria-label="Save" - className="ui basic icon primary right floated button" - onClick={[Function]} - title="Save" - type="submit" - > - <svg - className="icon circled" - dangerouslySetInnerHTML={ - Object { - "__html": undefined, - } - } - onClick={null} - style={ - Object { - "fill": "currentColor", - "height": "30px", - "width": "auto", - } - } - viewBox="" - xmlns="" - /> - </button> - <button - aria-label="Cancel" - className="ui basic icon secondary right floated button" - onClick={[Function]} - title="Cancel" - > - <svg - className="icon circled" - dangerouslySetInnerHTML={ - Object { - "__html": undefined, - } - } - onClick={null} - style={ - Object { - "fill": "currentColor", - "height": "30px", - "width": "auto", - } - } - viewBox="" - xmlns="" - /> - </button> - </div> - </form> - </div> - <div - id="Portal" - /> -</div> -`; diff --git a/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx b/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx deleted file mode 100644 index 4ea475af..00000000 --- a/src/customizations/volto/components/manage/Widgets/ObjectBrowserWidget.test.jsx +++ /dev/null @@ -1,191 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import { Provider } from 'react-intl-redux'; -import configureStore from 'redux-mock-store'; -import { Router } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; -import ObjectBrowserWidgetComponent from '@plone/volto/components/manage/Widgets/ObjectBrowserWidget'; -import '@testing-library/jest-dom/extend-expect'; - -const mockStore = configureStore(); -let store; -let history; - -describe('ObjectBrowserWidgetComponent', () => { - beforeEach(() => { - store = mockStore({ - search: { - subrequests: { - 'testBlock-multiple': {}, - 'testBlock-link': {}, - }, - }, - intl: { - locale: 'en', - messages: {}, - }, - }); - history = createMemoryHistory(); - }); - - it('renders without crashing', () => { - const { container } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - onChange={() => {}} - openObjectBrowser={() => {}} - /> - </Router> - </Provider>, - ); - - expect(container).toBeTruthy(); - }); - - it('renders without crashing with values, mode different than multiple, and description', () => { - const { container } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - value={[{ '@id': 'http://locahost:3000/test' }, { title: 'test2' }]} - mode="custom" - description="My description" - onChange={() => {}} - openObjectBrowser={() => {}} - /> - </Router> - </Provider>, - ); - - expect(container).toBeTruthy(); - }); - - it('renders without crashing with values, mode different than multiple and triggers the cancel function', () => { - const { container, getByPlaceholderText } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - mode="custom" - description="My description" - onChange={() => {}} - openObjectBrowser={() => {}} - allowExternals={true} - placeholder="My placeholder" - /> - </Router> - </Provider>, - ); - - fireEvent.change(getByPlaceholderText('My placeholder'), { - target: { value: 'http://localhost:8080/Plone/test' }, - }); - - expect(container.querySelector('button.cancel')).toBeInTheDocument(); - fireEvent.click(container.querySelector('button.cancel')); - - expect(container.querySelector('button.action')).toBeInTheDocument(); - fireEvent.click(container.querySelector('button.action')); - }); - - it('renders without crashing with values, mode different than multiple with placeholder and triggers the cancel function', () => { - const { container, getByPlaceholderText } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - mode="custom" - description="My description" - onChange={() => {}} - openObjectBrowser={() => {}} - allowExternals={true} - /> - </Router> - </Provider>, - ); - - expect(getByPlaceholderText('No items selected')).toBeInTheDocument(); - fireEvent.change(getByPlaceholderText('No items selected'), { - target: { value: 'test' }, - }); - - expect(container.querySelector('button.cancel')).toBeInTheDocument(); - fireEvent.click(container.querySelector('button.cancel')); - - expect(container.querySelector('button.action')).toBeInTheDocument(); - - expect(container).toBeTruthy(); - }); - - it('renders without crashing with values, mode different than multiple and triggers for keydown, change and submit', () => { - const { container, getByPlaceholderText } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - mode="custom" - description="My description" - onChange={() => {}} - openObjectBrowser={() => {}} - allowExternals={true} - /> - </Router> - </Provider>, - ); - - expect(getByPlaceholderText('No items selected')).toBeInTheDocument(); - fireEvent.keyDown(getByPlaceholderText('No items selected'), { - key: 'Enter', - code: 'Enter', - charCode: 13, - }); - fireEvent.keyDown(getByPlaceholderText('No items selected'), { - key: 'Escape', - code: 'Escape', - charCode: 27, - }); - fireEvent.keyDown(getByPlaceholderText('No items selected'), { - key: 'A', - code: 'KeyA', - }); - - fireEvent.change(getByPlaceholderText('No items selected'), { - target: { value: 'http://localhost:3000/Plone/test' }, - }); - expect(container.querySelector('button.primary')).toBeInTheDocument(); - - fireEvent.click(container.querySelector('button.cancel')); - - expect(container).toBeTruthy(); - }); - - it('renders without crashing with values, mode different than multiple and triggers for click on the Popup', () => { - const { container } = render( - <Provider store={store}> - <Router history={history}> - <ObjectBrowserWidgetComponent - id="my-widget" - title="My widget" - value={[{ '@id': 'http://locahost:3000/test', title: 'Title 1' }]} - description="My description" - onChange={() => {}} - openObjectBrowser={() => {}} - /> - </Router> - </Provider>, - ); - - expect(container.querySelector('.icon.right')).toBeInTheDocument(); - fireEvent.click(container.querySelector('.icon.right')); - expect(container).toBeTruthy(); - }); -}); diff --git a/src/customizations/volto/components/manage/Workflow/Workflow.jsx b/src/customizations/volto/components/manage/Workflow/Workflow.jsx index 5f6ff2e5..f743fce9 100644 --- a/src/customizations/volto/components/manage/Workflow/Workflow.jsx +++ b/src/customizations/volto/components/manage/Workflow/Workflow.jsx @@ -1,23 +1,18 @@ -/** - * Workflow component. - * @module components/manage/Workflow/Workflow - */ - -import { FormFieldWrapper, Icon, Toast } from '@plone/volto/components'; +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { uniqBy } from 'lodash'; +import { toast } from 'react-toastify'; +import { defineMessages, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import { Icon, Toast } from '@plone/volto/components'; +import { FormFieldWrapper } from '@plone/volto/components'; import { flattenToAppURL, - getCurrentStateMapping, getWorkflowOptions, + getCurrentStateMapping, } from '@plone/volto/helpers'; -import { uniqBy } from 'lodash'; -import PropTypes from 'prop-types'; -import { Component, Fragment } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { compose } from 'redux'; - import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; import { @@ -25,45 +20,29 @@ import { getWorkflow, transitionWorkflow, } from '@plone/volto/actions'; - -import checkSVG from '@plone/volto/icons/check.svg'; import downSVG from '@plone/volto/icons/down-key.svg'; import upSVG from '@plone/volto/icons/up-key.svg'; +import checkSVG from '@plone/volto/icons/check.svg'; const messages = defineMessages({ messageUpdated: { id: 'Workflow updated.', defaultMessage: 'Workflow updated.', }, - notAllowedToUpdateWorkflow: { - id: 'notAllowedToUpdateWorkflow', - defaultMessage: 'Please fill out all the required fields', - }, messageNoWorkflow: { id: 'No workflow', defaultMessage: 'No workflow', }, + notAllowedToUpdateWorkflow: { + id: 'notAllowedToUpdateWorkflow', + defaultMessage: 'Please fill out all the required fields', + }, state: { id: 'State', defaultMessage: 'State', }, }); -const filter_remaining_steps = (values, key) => { - return values.filter((value) => { - const is_not_ready = !value.is_ready; - if (!is_not_ready) { - return false; - } - const states = value.states; - const required_for_all = states?.indexOf('all') !== -1; - return ( - (is_not_ready && required_for_all) || - (is_not_ready && states?.indexOf(key) !== -1) - ); - }); -}; - const SingleValue = injectLazyLibs('reactSelect')(({ children, ...props }) => { const stateDecorator = { marginRight: '10px', @@ -185,98 +164,85 @@ const customSelectStyles = { }), }; -/** - * Workflow container class. - * @class Workflow - * @extends Component - */ -class Workflow extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - getContent: PropTypes.func.isRequired, - getWorkflow: PropTypes.func.isRequired, - transitionWorkflow: PropTypes.func.isRequired, - workflowLoaded: PropTypes.bool, - loaded: PropTypes.bool.isRequired, - pathname: PropTypes.string.isRequired, +function useWorkflow() { + const history = useSelector((state) => state.workflow.history, shallowEqual); + const transitions = useSelector( + (state) => state.workflow.transitions, + shallowEqual, + ); + const editingProgressSteps = useSelector((state) => + state?.editingProgress?.editing?.loaded === true + ? state?.editingProgress?.result?.steps + : [], + ); + const workflowLoaded = useSelector((state) => state.workflow.get?.loaded); + const loaded = useSelector((state) => state.workflow.transition.loaded); + const currentStateValue = useSelector( + (state) => getCurrentStateMapping(state.workflow.currentState), + shallowEqual, + ); - contentHistory: PropTypes.arrayOf( - PropTypes.shape({ - review_state: PropTypes.string, - }), - ), - transitions: PropTypes.arrayOf( - PropTypes.shape({ - '@id': PropTypes.string, - title: PropTypes.string, - }), - ), + return { + loaded, + history, + transitions, + currentStateValue, + workflowLoaded, + editingProgressSteps, }; +} - /** - * Default properties - * @property {Object} defaultProps Default properties. - * @static - */ - static defaultProps = { - history: [], - transitions: [], - }; +const filter_remaining_steps = (values, key) => { + return values.filter((value) => { + const is_not_ready = !value.is_ready; - state = { - selectedOption: this.props.currentStateValue, - }; + if (!is_not_ready) { + return false; + } - componentDidMount() { - this.props.getWorkflow(this.props.pathname); - } + const states = value.states; + const required_for_all = states?.indexOf('all') !== -1; - /** - * Component will receive props - * @method componentWillReceiveProps - * @param {Object} nextProps Next properties - * @returns {undefined} - */ - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.pathname !== this.props.pathname) { - this.props.getWorkflow(nextProps.pathname); - } - if (!this.props.loaded && nextProps.loaded) { - this.props.getWorkflow(nextProps.pathname); - this.props.getContent(nextProps.pathname); - } - if (!this.props.workflowLoaded && nextProps.workflowLoaded) { - this.props.getContent(nextProps.pathname); - // #153145 - Redirect to the newly created version - if (this.state?.selectedOption?.value === 'createNewVersion') { - this.props.history.push(`${nextProps.pathname}.1`); - } - } - } + return ( + (is_not_ready && required_for_all) || + (is_not_ready && states?.indexOf(key) !== -1) + ); + }); +}; + +const Workflow = (props) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useDispatch(); + const { + loaded, + currentStateValue, + transitions, + workflowLoaded, + editingProgressSteps, + } = useWorkflow(); + const content = useSelector((state) => state.content?.data, shallowEqual); + const [selectedOption, setSelectedOption] = React.useState(currentStateValue); + const { pathname } = props; - /** - * On transition handler - * @method transition - * @param {string} event Event object - * @returns {undefined} - */ - transition = (selectedOption) => { + useEffect(() => { + dispatch(getWorkflow(pathname)); + dispatch(getContent(pathname)); + }, [dispatch, pathname, loaded]); + + const transition = (selectedOption) => { if ( filter_remaining_steps( - this.props.editingProgressSteps, - this.props?.content?.review_state || '', + editingProgressSteps, + props?.content?.review_state || '', ).length === 0 ) { - this.props.transitionWorkflow(flattenToAppURL(selectedOption.url)); - this.setState({ selectedOption }); + dispatch(transitionWorkflow(flattenToAppURL(selectedOption.url))); + setSelectedOption(selectedOption); toast.success( <Toast success - title={this.props.intl.formatMessage(messages.messageUpdated)} + title={intl.formatMessage(messages.messageUpdated)} content="" />, ); @@ -284,138 +250,75 @@ class Workflow extends Component { toast.error( <Toast error - title={this.props.intl.formatMessage( - messages.notAllowedToUpdateWorkflow, - )} + title={intl.formatMessage(messages.notAllowedToUpdateWorkflow)} content="" />, ); } }; - selectValue = (option) => { - const stateDecorator = { - marginLeft: '10px', - marginRight: '10px', - display: 'inline-block', - backgroundColor: option.color || null, - content: ' ', - height: '10px', - width: '10px', - borderRadius: '50%', - }; - return ( - <Fragment> - <span style={stateDecorator} /> - <span className="Select-value-label">{option.label}</span> - </Fragment> - ); - }; + useEffect(() => { + if (selectedOption?.value === 'createNewVersion' && workflowLoaded) { + history.push(`${pathname}.1`); + } + }, [history, pathname, selectedOption?.value, workflowLoaded]); - optionRenderer = (option) => { - const stateDecorator = { - marginLeft: '10px', - marginRight: '10px', - display: 'inline-block', - backgroundColor: - this.props.currentStateValue.value === option.value - ? option.color - : null, - content: ' ', - height: '10px', - width: '10px', - borderRadius: '50%', - border: - this.props.currentStateValue.value !== option.value - ? `1px solid ${option.color}` - : null, - }; + const { Placeholder } = props.reactSelect.components; + const Select = props.reactSelect.default; - return ( - <Fragment> - <span style={stateDecorator} /> - <span style={{ marginRight: 'auto' }}>{option.label}</span> - <Icon name={checkSVG} size="24px" /> - </Fragment> - ); - }; + const filterd_transitions = transitions.filter((transition) => { + if ( + transition?.['@id']?.endsWith('markForDeletion') && + props.content?.review_state === 'published' + ) { + return false; + } + return true; + }); - render() { - const { Placeholder } = this.props.reactSelect.components; - const Select = this.props.reactSelect.default; - // Remove markForDeletion transition if item is published - // in order not to un-publish items by mistake. This transition - // can still be executed from /contents - refs #256563, #153145 - const transitions = this.props.transitions.filter((transition) => { - if ( - transition?.['@id']?.endsWith('markForDeletion') && - this.props?.content?.review_state === 'published' - ) { - return false; - } - return true; - }); + return ( + <FormFieldWrapper + id="state-select" + title={intl.formatMessage(messages.state)} + intl={intl} + {...props} + > + <Select + name="state-select" + className="react-select-container" + classNamePrefix="react-select" + isDisabled={!content.review_state || filterd_transitions.length === 0} + options={uniqBy( + filterd_transitions.map((transition) => + getWorkflowOptions(transition), + ), + 'label', + ).concat(currentStateValue)} + styles={customSelectStyles} + theme={selectTheme} + components={{ + DropdownIndicator, + Placeholder, + Option, + SingleValue, + }} + onChange={transition} + value={ + content.review_state + ? currentStateValue + : { + label: intl.formatMessage(messages.messageNoWorkflow), + value: 'noworkflow', + } + } + isSearchable={false} + /> + </FormFieldWrapper> + ); +}; - return ( - <FormFieldWrapper - id="state-select" - title={this.props.intl.formatMessage(messages.state)} - {...this.props} - > - <Select - name="state-select" - className="react-select-container" - classNamePrefix="react-select" - isDisabled={ - !this.props.content.review_state || transitions.length === 0 - } - options={uniqBy( - transitions.map((transition) => getWorkflowOptions(transition)), - 'label', - ).concat(this.props.currentStateValue)} - styles={customSelectStyles} - theme={selectTheme} - components={{ - DropdownIndicator, - Placeholder, - Option, - SingleValue, - }} - onChange={this.transition} - value={ - this.props.content.review_state - ? this.props.currentStateValue - : { - label: this.props.intl.formatMessage( - messages.messageNoWorkflow, - ), - value: 'noworkflow', - } - } - isSearchable={false} - /> - </FormFieldWrapper> - ); - } -} +Workflow.propTypes = { + pathname: PropTypes.string.isRequired, +}; -export default compose( - injectIntl, - injectLazyLibs(['reactSelect']), - withRouter, - connect( - (state, props) => ({ - loaded: state.workflow.transition.loaded, - content: state.content.data, - workflowLoaded: state.workflow.get?.loaded, - contentHistory: state.workflow.history, - transitions: state.workflow.transitions, - currentStateValue: getCurrentStateMapping(state.workflow.currentState), - editingProgressSteps: - state?.editingProgress?.editing?.loaded === true - ? state?.editingProgress?.result?.steps - : [], - }), - { getContent, getWorkflow, transitionWorkflow }, - ), -)(Workflow); +export default compose(injectLazyLibs(['reactSelect']))(Workflow); diff --git a/src/customizations/volto/components/manage/Workflow/xxx b/src/customizations/volto/components/manage/Workflow/xxx deleted file mode 100644 index 90996c5a..00000000 --- a/src/customizations/volto/components/manage/Workflow/xxx +++ /dev/null @@ -1,246 +0,0 @@ -import { useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { compose } from 'redux'; -import { useDispatch, useSelector, shallowEqual } from 'react-redux'; -import { uniqBy } from 'lodash'; -import { toast } from 'react-toastify'; -import { defineMessages, useIntl } from 'react-intl'; - -import { FormFieldWrapper, Icon, Toast } from '@plone/volto/components'; -import { - flattenToAppURL, - getWorkflowOptions, - getCurrentStateMapping, -} from '@plone/volto/helpers'; -import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; - -import { - getContent, - getWorkflow, - transitionWorkflow, -} from '@plone/volto/actions'; -import downSVG from '@plone/volto/icons/down-key.svg'; -import upSVG from '@plone/volto/icons/up-key.svg'; -import checkSVG from '@plone/volto/icons/check.svg'; - -const messages = defineMessages({ - messageUpdated: { - id: 'Workflow updated.', - defaultMessage: 'Workflow updated.', - }, - messageNoWorkflow: { - id: 'No workflow', - defaultMessage: 'No workflow', - }, - state: { - id: 'State', - defaultMessage: 'State', - }, -}); - -const SingleValue = injectLazyLibs('reactSelect')(({ children, ...props }) => { - const stateDecorator = { - marginRight: '10px', - display: 'inline-block', - backgroundColor: props.selectProps.value.color || null, - content: ' ', - height: '10px', - width: '10px', - borderRadius: '50%', - }; - const { SingleValue } = props.reactSelect.components; - return ( - <SingleValue {...props}> - <span style={stateDecorator} /> - {children} - </SingleValue> - ); -}); - -const Option = injectLazyLibs('reactSelect')((props) => { - const stateDecorator = { - marginRight: '10px', - display: 'inline-block', - backgroundColor: - props.selectProps.value.value === props.data.value - ? props.selectProps.value.color - : null, - content: ' ', - height: '10px', - width: '10px', - borderRadius: '50%', - border: - props.selectProps.value.value !== props.data.value - ? `1px solid ${props.data.color}` - : null, - }; - - const { Option } = props['reactSelect'].components; - return ( - <Option {...props}> - <span style={stateDecorator} /> - <div style={{ marginRight: 'auto' }}>{props.label}</div> - {props.isFocused && !props.isSelected && ( - <Icon name={checkSVG} size="18px" color="#b8c6c8" /> - )} - {props.isSelected && <Icon name={checkSVG} size="18px" color="#007bc1" />} - </Option> - ); -}); - -const DropdownIndicator = injectLazyLibs('reactSelect')((props) => { - const { DropdownIndicator } = props.reactSelect.components; - return ( - <DropdownIndicator {...props} data-testid="workflow-select-dropdown"> - {props.selectProps.menuIsOpen ? ( - <Icon name={upSVG} size="24px" color="#007bc1" /> - ) : ( - <Icon name={downSVG} size="24px" color="#007bc1" /> - )} - </DropdownIndicator> - ); -}); - -const selectTheme = (theme) => ({ - ...theme, - borderRadius: 0, - colors: { - ...theme.colors, - primary25: 'hotpink', - primary: '#b8c6c8', - }, -}); - -const customSelectStyles = { - control: (styles, state) => ({ - ...styles, - border: 'none', - borderBottom: '2px solid #b8c6c8', - boxShadow: 'none', - borderBottomStyle: state.menuIsOpen ? 'dotted' : 'solid', - }), - menu: (styles, state) => ({ - ...styles, - top: null, - marginTop: 0, - boxShadow: 'none', - borderBottom: '2px solid #b8c6c8', - }), - indicatorSeparator: (styles) => ({ - ...styles, - width: null, - }), - valueContainer: (styles) => ({ - ...styles, - padding: 0, - }), - option: (styles, state) => ({ - ...styles, - backgroundColor: null, - minHeight: '50px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '12px 12px', - color: state.isSelected - ? '#007bc1' - : state.isFocused - ? '#4a4a4a' - : 'inherit', - ':active': { - backgroundColor: null, - }, - span: { - flex: '0 0 auto', - }, - svg: { - flex: '0 0 auto', - }, - }), -}; - -function useWorkflow() { - const history = useSelector((state) => state.workflow.history, shallowEqual); - const transitions = useSelector( - (state) => state.workflow.transitions, - shallowEqual, - ); - const loaded = useSelector((state) => state.workflow.transition.loaded); - const currentStateValue = useSelector( - (state) => getCurrentStateMapping(state.workflow.currentState), - shallowEqual, - ); - - return { loaded, history, transitions, currentStateValue }; -} - -const Workflow = (props) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const { loaded, transitions, currentStateValue } = useWorkflow(); - const content = useSelector((state) => state.content?.data, shallowEqual); - const { pathname } = props; - - useEffect(() => { - dispatch(getWorkflow(pathname)); - dispatch(getContent(pathname)); - }, [dispatch, pathname, loaded]); - - const transition = (selectedOption) => { - dispatch(transitionWorkflow(flattenToAppURL(selectedOption.url))); - toast.success( - <Toast - success - title={intl.formatMessage(messages.messageUpdated)} - content="" - />, - ); - }; - - const { Placeholder } = props.reactSelect.components; - const Select = props.reactSelect.default; - - return ( - <FormFieldWrapper - id="state-select" - title={intl.formatMessage(messages.state)} - intl={intl} - {...props} - > - <Select - name="state-select" - className="react-select-container" - classNamePrefix="react-select" - isDisabled={!content.review_state || transitions.length === 0} - options={uniqBy( - transitions.map((transition) => getWorkflowOptions(transition)), - 'label', - ).concat(currentStateValue)} - styles={customSelectStyles} - theme={selectTheme} - components={{ - DropdownIndicator, - Placeholder, - Option, - SingleValue, - }} - onChange={transition} - value={ - content.review_state - ? currentStateValue - : { - label: intl.formatMessage(messages.messageNoWorkflow), - value: 'noworkflow', - } - } - isSearchable={false} - /> - </FormFieldWrapper> - ); -}; - -Workflow.propTypes = { - pathname: PropTypes.string.isRequired, -}; - -export default compose(injectLazyLibs(['reactSelect']))(Workflow); diff --git a/src/customizations/volto/components/theme/AppExtras/AppExtras.jsx b/src/customizations/volto/components/theme/AppExtras/AppExtras.jsx deleted file mode 100644 index 7fd492fe..00000000 --- a/src/customizations/volto/components/theme/AppExtras/AppExtras.jsx +++ /dev/null @@ -1,27 +0,0 @@ -//this should be deleted when upgraded to a volto version that supports App Extras exceptions -import React from 'react'; -import { matchPath } from 'react-router'; -import config from '@plone/volto/registry'; - -const AppExtras = (props) => { - const { settings } = config; - const { appExtras = [] } = settings; - const { pathname } = props; - const active = appExtras - .map((reg) => { - const excluded = matchPath(pathname, reg.exclude); - if (excluded) return null; - const match = matchPath(pathname, reg.match); - return match ? { reg, match } : null; - }) - .filter((reg) => reg); - - return active.map(({ reg: { component, props: extraProps }, match }, i) => { - const Insert = component; - return ( - <Insert key={`appextra-${i}`} match={match} {...props} {...extraProps} /> - ); - }); -}; - -export default AppExtras; From ffe3049b3b656093a44f05044dbe7cd63bac495f Mon Sep 17 00:00:00 2001 From: David Ichim <ichim.david@gmail.com> Date: Fri, 10 May 2024 19:51:20 +0300 Subject: [PATCH 14/15] Bump package version to 2.0.0 to signal major release due to Volto 17 jump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a4aa8a25..ffdba8c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eeacms/volto-eea-website-theme", - "version": "1.33.3", + "version": "2.0.0", "description": "@eeacms/volto-eea-website-theme: Volto add-on", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", From bdcd5e89fb22f8383f7e41464e103a30c3cadd92 Mon Sep 17 00:00:00 2001 From: EEA Jenkins <@users.noreply.github.com> Date: Fri, 10 May 2024 17:04:10 +0000 Subject: [PATCH 15/15] Automated release 2.0.0 --- CHANGELOG.md | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a1a961..67a73d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -### [1.33.3](https://github.com/eea/volto-eea-website-theme/compare/1.34.0...1.33.3) - 10 May 2024 +### [2.0.0](https://github.com/eea/volto-eea-website-theme/compare/1.34.0...2.0.0) - 10 May 2024 #### :rocket: New Features @@ -26,6 +26,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - style: Automated code fix [eea-jenkins - [`e671c83`](https://github.com/eea/volto-eea-website-theme/commit/e671c834773f9091e1dafb8ec7a1cbea88e53ee2)] - style: Automated code fix [eea-jenkins - [`5156bb5`](https://github.com/eea/volto-eea-website-theme/commit/5156bb54b48f9731278ea860847a019fff10a84f)] +#### :hammer_and_wrench: Others + +- Bump package version to 2.0.0 to signal major release due to Volto 17 jump [David Ichim - [`ffe3049`](https://github.com/eea/volto-eea-website-theme/commit/ffe3049b3b656093a44f05044dbe7cd63bac495f)] ### [1.34.0](https://github.com/eea/volto-eea-website-theme/compare/1.33.2...1.34.0) - 9 May 2024 #### :bug: Bug Fixes @@ -52,7 +55,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- Add Sonarqube tag using insitu-frontend addons list [EEA Jenkins - [`adc6730`](https://github.com/eea/volto-eea-website-theme/commit/adc6730e21a37afb865b842182624401de6a29f5)] ### [1.33.1](https://github.com/eea/volto-eea-website-theme/compare/1.33.0...1.33.1) - 4 April 2024 #### :bug: Bug Fixes @@ -186,8 +188,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - bump version [Razvan - [`721e939`](https://github.com/eea/volto-eea-website-theme/commit/721e939d12e324b459ebfa78a2e656ee7142a3d6)] - merge master into this branch [Razvan - [`586c8f9`](https://github.com/eea/volto-eea-website-theme/commit/586c8f910bac55a043bd8dda60e9444bd2ae1663)] -- Add Sonarqube tag using freshwater-frontend addons list [EEA Jenkins - [`fd90044`](https://github.com/eea/volto-eea-website-theme/commit/fd9004442a9d1d465f7601ecdefe3e23c61e6a9c)] -- Add Sonarqube tag using insitu-frontend addons list [EEA Jenkins - [`4bc3dd3`](https://github.com/eea/volto-eea-website-theme/commit/4bc3dd3ae412a66befd04b5b80fab3716c929240)] - test: Update jest,Jenkinsfile,lint to volto-addons-template PR30 [valentinab25 - [`c4dbd28`](https://github.com/eea/volto-eea-website-theme/commit/c4dbd289358205bc2d849aab7edb11ccf3b89cee)] - fix tests [Razvan - [`042330b`](https://github.com/eea/volto-eea-website-theme/commit/042330bc97d32ffe7ba769b4f2453f71cffed706)] - remove RemoveSchema logic [Razvan - [`08d10f8`](https://github.com/eea/volto-eea-website-theme/commit/08d10f8bf6f75478260e4e4c66d7316ba87b907a)] @@ -282,11 +282,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - test: Add real image to cypress test [Alin Voinea - [`4ff591a`](https://github.com/eea/volto-eea-website-theme/commit/4ff591ae3318c9588b4e2114582c0fa6cfdf31ae)] - test: Add cypress tests for Image block styling position and align [Alin Voinea - [`7341ef7`](https://github.com/eea/volto-eea-website-theme/commit/7341ef7b92714fc0cc3ab0c31c39033e7b3e19e7)] - Revert "change(tests): commented out rss test since title block config is missing" [Alin Voinea - [`fb61191`](https://github.com/eea/volto-eea-website-theme/commit/fb611918d6ca380b89b594f283dcf9f685a4b294)] -- test: [JENKINS] Use java17 for sonarqube scanner [valentinab25 - [`6a3be30`](https://github.com/eea/volto-eea-website-theme/commit/6a3be3092589411af7808a235f76de5222fd3868)] -- test: [JENKINS] Run cypress in started frontend container [valentinab25 - [`c3978f2`](https://github.com/eea/volto-eea-website-theme/commit/c3978f23375ef066e9fd6f6c2e34ba6c1c058f69)] -- test: [JENKINS] Add cpu limit on cypress docker [valentinab25 - [`f672779`](https://github.com/eea/volto-eea-website-theme/commit/f672779e845bec9240ccc901e9f53ec80c5a1819)] -- test: [JENKINS] Increase shm-size to cypress docker [valentinab25 - [`ae5d8e3`](https://github.com/eea/volto-eea-website-theme/commit/ae5d8e3f4e04dc2808d47ce2ee886e1b23b528da)] -- test: [JENKINS] Improve cypress time [valentinab25 - [`170ff0c`](https://github.com/eea/volto-eea-website-theme/commit/170ff0c8e3b30e69479bdf1117e811fea94f1027)] ### [1.23.0](https://github.com/eea/volto-eea-website-theme/compare/1.22.1...1.23.0) - 2 November 2023 #### :rocket: New Features @@ -299,7 +294,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :house: Internal changes -- chore: [JENKINS] Refactor automated testing [valentinab25 - [`f28fce3`](https://github.com/eea/volto-eea-website-theme/commit/f28fce3d1eb815f95fb9aa40de42b10b7e8e30c5)] - chore: husky, lint-staged use fixed versions [valentinab25 - [`6d15088`](https://github.com/eea/volto-eea-website-theme/commit/6d150886c5aeb2ca0b569270486e60f7cc274e2c)] - chore:volto 16 in tests, update docs, fix stylelint overrides [valentinab25 - [`20c0323`](https://github.com/eea/volto-eea-website-theme/commit/20c032380b33c0077c869a05136f93e2fb68e5d4)] @@ -485,7 +479,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :house: Internal changes -- chore: [JENKINS] Deprecate circularity website [valentinab25 - [`370dcbf`](https://github.com/eea/volto-eea-website-theme/commit/370dcbfbf1a8135ce7b1b3b271b004552a631837)] #### :hammer_and_wrench: Others @@ -641,7 +634,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`6c5e2f8`](https://github.com/eea/volto-eea-website-theme/commit/6c5e2f80456e2061d9e9c15fd0a0b91b9ac70568)] ### [1.9.1](https://github.com/eea/volto-eea-website-theme/compare/1.9.0...1.9.1) - 28 February 2023 #### :bug: Bug Fixes @@ -788,7 +780,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - For some reasons types is a string [Alin Voinea - [`3769a09`](https://github.com/eea/volto-eea-website-theme/commit/3769a0981181d5b633f3498daebbe96be8b4b833)] - Fix(redirect): o.filter - refs #157627 [Alin Voinea - [`deb23da`](https://github.com/eea/volto-eea-website-theme/commit/deb23da846444cc96539697fd798429ae0abe89e)] -- Add Sonarqube tag using advisory-board-frontend addons list [EEA Jenkins - [`f1fffc5`](https://github.com/eea/volto-eea-website-theme/commit/f1fffc5db96725440863d545580b4e76cce4b796)] ### [1.5.0](https://github.com/eea/volto-eea-website-theme/compare/1.4.2...1.5.0) - 9 January 2023 #### :hammer_and_wrench: Others @@ -822,7 +813,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - Release 1.4.0 [Alin Voinea - [`bd42a0d`](https://github.com/eea/volto-eea-website-theme/commit/bd42a0d26e928cac5d99933194755da3db06b341)] - bump version to use as volto-eea-design-system [David Ichim - [`f4be047`](https://github.com/eea/volto-eea-website-theme/commit/f4be047328b46399b03b612d378b18aaf82e7dc1)] -- Add Sonarqube tag using advisory-board-frontend addons list [EEA Jenkins - [`9b7cfef`](https://github.com/eea/volto-eea-website-theme/commit/9b7cfefb4d34fc1c948015e491feb370f9795bd8)] - test(Jenkins): Run tests and cypress with latest canary @plone/volto [Alin Voinea - [`df252a9`](https://github.com/eea/volto-eea-website-theme/commit/df252a9bfed0bb86cadf53c59dd1603b1e2cd822)] ### [1.3.2](https://github.com/eea/volto-eea-website-theme/compare/1.3.1...1.3.2) - 16 December 2022 @@ -832,7 +822,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- Add Sonarqube tag using cca-frontend addons list [EEA Jenkins - [`a43c658`](https://github.com/eea/volto-eea-website-theme/commit/a43c658a7920c8df95e763b9a637f38ce77eba2c)] - Better razzle.config [Tiberiu Ichim - [`81dbf48`](https://github.com/eea/volto-eea-website-theme/commit/81dbf48815fb27facb4f82c9b764540fdf188b2e)] - Better razzle.config [Tiberiu Ichim - [`7bc9da2`](https://github.com/eea/volto-eea-website-theme/commit/7bc9da2cd837ab62a95cd29979cdd9b0055b7d67)] ### [1.3.1](https://github.com/eea/volto-eea-website-theme/compare/1.3.0...1.3.1) - 28 November 2022 @@ -843,7 +832,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- yarn 3 [Alin Voinea - [`ea7a709`](https://github.com/eea/volto-eea-website-theme/commit/ea7a7094945312776e9b6f44e371178603e92139)] ### [1.3.0](https://github.com/eea/volto-eea-website-theme/compare/1.2.0...1.3.0) - 22 November 2022 #### :rocket: New Features @@ -884,7 +872,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - Add subsite class to body [Tiberiu Ichim - [`74d700f`](https://github.com/eea/volto-eea-website-theme/commit/74d700fbfd6249a8604762a7e4e49cce857db0f3)] - Add subsite info to header [Tiberiu Ichim - [`47daf8b`](https://github.com/eea/volto-eea-website-theme/commit/47daf8bb6374a1222040626b19d4154df7ba1b83)] - fix eslint [Miu Razvan - [`eb8d0a7`](https://github.com/eea/volto-eea-website-theme/commit/eb8d0a790bc70c0aae256c6ff35f63c4885f338e)] -- Add Sonarqube tag using circularity-frontend addons list [EEA Jenkins - [`cc578a4`](https://github.com/eea/volto-eea-website-theme/commit/cc578a413b205a8e61e091fab3a88f94cedefc89)] ### [1.1.0](https://github.com/eea/volto-eea-website-theme/compare/1.0.0...1.1.0) - 28 October 2022 #### :nail_care: Enhancements @@ -932,7 +919,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`33b56ac`](https://github.com/eea/volto-eea-website-theme/commit/33b56acb13fbaf0c5b79e8fc6e13c4b699c79c90)] ### [0.7.3](https://github.com/eea/volto-eea-website-theme/compare/0.7.2...0.7.3) - 22 September 2022 #### :hammer_and_wrench: Others @@ -1200,7 +1186,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - Header refactor, add custom logo #5 [ichim-david - [`4950235`](https://github.com/eea/volto-eea-website-theme/commit/49502358105437cfeac3b144e6d301cb59aa2346)] - Update footer.config with new publication card component [ichim-david - [`2e38e9a`](https://github.com/eea/volto-eea-website-theme/commit/2e38e9a417f835009d60c80d4eb4b30229f55e45)] - feature(breadcrumbs): implement eea-design-system breadcrumb as Volto component #32 #7 [ichim-david - [`181af41`](https://github.com/eea/volto-eea-website-theme/commit/181af4125ce2b9ddac56dab4723cb11c26633221)] -- Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`da8ceb6`](https://github.com/eea/volto-eea-website-theme/commit/da8ceb68ea68bfbc9504e48ccd4d68277f11ab9a)] - use breadcrumbs from eea-design-system [nileshgulia1 - [`db2f9e9`](https://github.com/eea/volto-eea-website-theme/commit/db2f9e9a4327420a3cce9a9903cd88549b129eab)] - Update theme.config [ichim-david - [`8eca4f4`](https://github.com/eea/volto-eea-website-theme/commit/8eca4f40397a4aeca6d39029c92db78968d37064)] - Added keyContent component to theme.config [ichim-david - [`d86f202`](https://github.com/eea/volto-eea-website-theme/commit/d86f202d0274d839487a88b51cae9a0e899beb23)] @@ -1242,5 +1227,4 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### :hammer_and_wrench: Others -- yarn bootstrap [Alin Voinea - [`6995e9e`](https://github.com/eea/volto-eea-website-theme/commit/6995e9e091f21fdbbdffa8a44fc0e2c626f6d46a)] - Initial commit [Alin Voinea - [`6a9c03a`](https://github.com/eea/volto-eea-website-theme/commit/6a9c03a7cebe71ca87e82cf58c42904063e9d8d3)]