diff --git a/cypress/e2e/01-hero-block-basics.cy.js b/cypress/e2e/01-hero-block-basics.cy.js index 18cb861..793cf54 100644 --- a/cypress/e2e/01-hero-block-basics.cy.js +++ b/cypress/e2e/01-hero-block-basics.cy.js @@ -19,9 +19,6 @@ describe('Blocks Tests', () => { 'hero', ); cy.get('.button.hero').click({ force: true }); - cy.get('.hero-block-text div[role="textbox"]') - .click() - .type('My Hero Block'); cy.get( '.inline.field.textarea.field-wrapper-buttonLabel textarea#field-buttonLabel', @@ -37,6 +34,10 @@ describe('Blocks Tests', () => { .eq(0) .click(); + cy.get('.hero-block-text div[role="textbox"]') + .click() + .type('My Hero Block'); + // Save cy.get('#toolbar-save').click(); cy.url().should('eq', Cypress.config().baseUrl + '/cypress/my-page'); diff --git a/cypress/e2e/02-dexterity-controlpanel-layout.cy.js b/cypress/e2e/02-dexterity-controlpanel-layout.cy.js index d7fbcd3..e6ce5e1 100644 --- a/cypress/e2e/02-dexterity-controlpanel-layout.cy.js +++ b/cypress/e2e/02-dexterity-controlpanel-layout.cy.js @@ -35,6 +35,7 @@ describe('ControlPanel: Dexterity Content-Types Layout', () => { cy.get('.blocks-chooser .title').contains('Common').click(); cy.get('.content.active.common .button.hero') .contains('Hero') + .click({ force: true }); cy.get('#toolbar-save').click(); @@ -48,17 +49,16 @@ describe('ControlPanel: Dexterity Content-Types Layout', () => { cy.get('button[class="add"]').click(); cy.get('#toolbar-add-book').click(); cy.get('.block.title').contains('Book title'); - cy.get('.block.hero div[role="presentation"]').click(); // Add text and button - cy.get('.hero-block-text div[role="textbox"]') - .click() - .type('My hero block'); - cy.get('label[for="field-fullWidth"]').click(); - cy.get('label[for="field-fullHeight"]').click(); + cy.get('.block.hero div[role="presentation"]').first().click(); + cy.get('.formtabs.menu').children().first().next().click(); + cy.get('label[for="field-quoted"]').click(); cy.get('label[for="field-spaced"]').click(); - cy.get('.field-wrapper-buttonLabel #field-buttonLabel').click().type('Label') + cy.get('.field-wrapper-buttonLabel #field-buttonLabel') + .click() + .type('Label'); cy.get('#field-buttonLabel').click().type('my button'); cy.get('.inline.field.field-attached-image .ui.input') .click() @@ -67,6 +67,10 @@ describe('ControlPanel: Dexterity Content-Types Layout', () => { '.inline.field.field-attached-image .ui.buttons .primary.button', ).click(); + cy.get('.hero-block-text div[role="textbox"]') + .click() + .type('My hero block'); + // Change book title cy.clearSlateTitle(); cy.getSlateTitle().type('My First Book'); @@ -78,6 +82,6 @@ describe('ControlPanel: Dexterity Content-Types Layout', () => { cy.get('.hero-block-meta .button').contains('my button'); cy.get('.hero-block-image-wrapper'); cy.get('.eea.hero-block.spaced.inverted.full-height'); - cy.get('.hero-block-meta.text-left .button').contains('Label') + cy.get('.hero-block-meta.text-left .button').contains('Label'); }); }); diff --git a/jest-addon.config.js b/jest-addon.config.js index a12548d..517e2f5 100644 --- a/jest-addon.config.js +++ b/jest-addon.config.js @@ -1,4 +1,4 @@ -require('dotenv').config({ path: __dirname + '/.env' }) +require('dotenv').config({ path: __dirname + '/.env' }); module.exports = { testMatch: ['**/src/addons/**/?(*.)+(spec|test).[jt]s?(x)'], @@ -28,6 +28,7 @@ module.exports = { ], transform: { '^.+\\.js(x)?$': 'babel-jest', + '^.+\\.ts(x)?$': 'ts-jest', '^.+\\.(png)$': 'jest-file', '^.+\\.(jpg)$': 'jest-file', '^.+\\.(svg)$': './node_modules/@plone/volto/jest-svgsystem-transform.js', @@ -45,4 +46,4 @@ module.exports = { '/node_modules/@eeacms/volto-hero-block/jest.setup.js', ], }), -} +}; diff --git a/package.json b/package.json index 1df8397..6c54482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eeacms/volto-hero-block", - "version": "5.5.0", + "version": "6.0.0", "description": "@eeacms/volto-hero-block: Volto add-on", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", @@ -29,6 +29,7 @@ "babel-plugin-transform-class-properties": "^6.24.1", "dotenv": "^16.3.2", "husky": "^8.0.3", + "ts-jest": "^26.4.2", "lint-staged": "^14.0.1", "md5": "^2.3.0" }, diff --git a/src/components/Blocks/Hero/Edit.jsx b/src/components/Blocks/Hero/Edit.jsx index ba01b07..ec5ad7a 100644 --- a/src/components/Blocks/Hero/Edit.jsx +++ b/src/components/Blocks/Hero/Edit.jsx @@ -1,23 +1,21 @@ -import React from 'react'; +import React, { useState } from 'react'; import cx from 'classnames'; import isFunction from 'lodash/isFunction'; import { Icon } from 'semantic-ui-react'; import config from '@plone/volto/registry'; +import { BlocksForm } from '@plone/volto/components'; +import EditBlockWrapper from './EditBlockWrapper'; +import { v4 as uuid } from 'uuid'; + +import { isEmpty } from 'lodash'; import { BlockDataForm, SidebarPortal, UniversalLink, } from '@plone/volto/components'; import { BodyClass } from '@plone/volto/helpers'; -import SlateEditor from '@plone/volto-slate/editor/SlateEditor'; -import { - handleKey, - handleKeyDetached, -} from '@plone/volto-slate/blocks/Text/keyboard'; -import { - createSlateHeader, - getFieldURL, -} from '@eeacms/volto-hero-block/helpers'; + +import { getFieldURL } from '@eeacms/volto-hero-block/helpers'; import { HeroBlockSchema } from './schema'; import Copyright from './Copyright'; import Hero from './Hero'; @@ -39,23 +37,21 @@ const Metadata = ({ buttonLabel, inverted, styles, ...props }) => { }; export default function Edit(props) { - const { slate } = config.settings; + const id = uuid(); + const [selectedBlock, setSelectedBlock] = useState(id); const { data = {}, block = null, - selected = false, - index, + selected, properties, onChangeBlock, - onSelectBlock, + onChangeField, + pathname, + metadata = null, + setSidebarTab, } = props; - const { - text, - copyright, - copyrightIcon, - copyrightPosition, - isMultiline, - } = data; + const { copyright, copyrightIcon, copyrightPosition } = data; + const copyrightPrefix = config.blocks.blocksConfig.hero.copyrightPrefix || ''; const schema = React.useMemo(() => { if (isFunction(HeroBlockSchema)) { @@ -64,80 +60,119 @@ export default function Edit(props) { return HeroBlockSchema; }, [props]); - const withBlockProperties = React.useCallback( - (editor) => { - editor.getBlockProps = () => props; - return editor; - }, - [props], - ); + const blockState = {}; + const data_blocks = data?.data?.blocks; - const handleFocus = React.useCallback(() => { - if (!selected) { - onSelectBlock(block); - } - }, [onSelectBlock, selected, block]); + if (data?.text || isEmpty(data_blocks)) { + let dataWithoutText = { ...data }; + if (dataWithoutText) delete dataWithoutText.text; - const extensions = React.useMemo(() => { - if (isMultiline) { - return slate.textblockExtensions.filter( - (f) => f.name !== 'withSplitBlocksOnBreak', - ); - } else { - return slate.textblockExtensions; - } - }, [slate.textblockExtensions, isMultiline]); - - const value = createSlateHeader(text); + onChangeBlock(block, { + ...dataWithoutText, + data: data?.text + ? { + blocks: { + [id]: { + '@type': 'slate', + value: data.text, + plaintext: data.text?.[0].children?.[0].text, + }, + }, + blocks_layout: { items: [id] }, + } + : { + blocks: { + [id]: { + '@type': 'slate', + value: [{ type: 'h2', children: [{ text: '' }] }], + plaintext: '', + }, + }, + blocks_layout: { items: [id] }, + }, + }); + } return ( <> - - - { - onChangeBlock(block, { - ...data, - text, - }); - }} - block={block} - onFocus={handleFocus} - onKeyDown={isMultiline ? handleKeyDetached : handleKey} - selected={selected} - placeholder="Add text..." - slateSettings={slate} - /> - - - - - {copyright ? ( - - {copyrightPrefix} - - - - {copyright} - - ) : ( - '' - )} - - +
{ + if (e.target?.className?.includes('hero')) { + setSelectedBlock(id); + setSidebarTab(1); + } + }} + > + + + { + setSelectedBlock(s); + }} + onChangeFormData={(newFormData) => { + onChangeBlock(block, { + ...data, + data: newFormData, + }); + }} + onChangeField={(id, value) => { + if (['blocks', 'blocks_layout'].indexOf(id) > -1) { + blockState[id] = value; + onChangeBlock(block, { + ...data, + data: { + ...data.data, + ...blockState, + }, + }); + } else { + onChangeField(id, value); + } + }} + pathname={pathname} + > + {({ draginfo }, editBlock, blockProps) => ( + + {editBlock} + + )} + + + + + + {copyright ? ( + + {copyrightPrefix} + + + + {copyright} + + ) : ( + '' + )} + +
{ onChangeBlock(block, { ...data, diff --git a/src/components/Blocks/Hero/Edit.test.jsx b/src/components/Blocks/Hero/Edit.test.jsx index 36428e0..b3a2e53 100644 --- a/src/components/Blocks/Hero/Edit.test.jsx +++ b/src/components/Blocks/Hero/Edit.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; import Edit from './Edit'; @@ -7,25 +7,33 @@ import config from '@plone/volto/registry'; import '@testing-library/jest-dom/extend-expect'; const mockStore = configureStore([]); - -jest.mock('@plone/volto-slate/editor/SlateEditor', () => { +const observe = jest.fn(); +const unobserve = jest.fn(); +jest.mock('@plone/volto/components', () => { return { __esModule: true, - default: ({ placeholder, children, onChange, onFocus }) => ( -
onChange(target)} - onFocus={() => onFocus()} - id="test" - > + BlocksForm: ({ placeholder, children, onChange, onFocus }) => ( +
{placeholder}
{children}
), + SidebarPortal: ({ children }) =>
{children}
, + BlockDataForm: () =>
, + UniversalLink: () =>
, + RenderBlocks: () =>
, }; }); +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn().mockReturnValue({ + pathname: '/test-jest', + search: '', + hash: '', + state: null, + key: 'test-jest', + }), +})); -const observe = jest.fn(); -const unobserve = jest.fn(); window.IntersectionObserver = jest.fn((callback) => ({ observe, unobserve, @@ -60,7 +68,7 @@ describe('Edit component', () => { it('renders without crashing', () => { const { container } = render( - + {}} onSelectBlock={() => {}} /> , ); expect(container).toBeTruthy(); @@ -82,35 +90,13 @@ describe('Edit component', () => { const { container } = render( - + {}} /> , ); expect(container.querySelector('#test')).toBeInTheDocument(); }); - it('calls onFocus when SlateEditor is focused', () => { - config.blocks = { - blocksConfig: { - hero: { - copyrightPrefix: 'Test Prefix', - schema: () => ({ - title: 'Hero', - }), - }, - }, - }; - const onSelectBlock = jest.fn(); - const { getByText } = render( - - - , - ); - - fireEvent.focus(getByText('Add text...')); - expect(onSelectBlock).toHaveBeenCalled(); - }); - it('renders without copyrightPrefix', () => { config.blocks = { blocksConfig: { @@ -124,7 +110,7 @@ describe('Edit component', () => { const onSelectBlock = jest.fn(); render( - + {}} /> , ); }); diff --git a/src/components/Blocks/Hero/EditBlockWrapper.jsx b/src/components/Blocks/Hero/EditBlockWrapper.jsx new file mode 100644 index 0000000..291bf05 --- /dev/null +++ b/src/components/Blocks/Hero/EditBlockWrapper.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Icon, BlockChooser } from '@plone/volto/components'; +import { blockHasValue } from '@plone/volto/helpers'; +import config from '@plone/volto/registry'; +import { Button } from 'semantic-ui-react'; +import includes from 'lodash/includes'; +import isBoolean from 'lodash/isBoolean'; +import { defineMessages, injectIntl } from 'react-intl'; +import { doesNodeContainClick } from 'semantic-ui-react/dist/commonjs/lib'; +import cx from 'classnames'; + +import dragSVG from '@plone/volto/icons/drag.svg'; +import addSVG from '@plone/volto/icons/circle-plus.svg'; +import trashSVG from '@plone/volto/icons/delete.svg'; + +const messages = defineMessages({ + unknownBlock: { + id: 'Unknown Block', + defaultMessage: 'Unknown Block {block}', + }, + delete: { + id: 'delete', + defaultMessage: 'delete', + }, +}); + +class EditBlockWrapper extends React.Component { + constructor(props) { + super(props); + this.state = { + addNewBlockOpened: false, + }; + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClickOutside, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside); + } + + handleClickOutside = (e) => { + if ( + this.blockNode.current && + doesNodeContainClick(this.blockNode.current, e) + ) { + return; + } + + if (this.state.addNewBlockOpened) { + this.setState({ + addNewBlockOpened: false, + }); + return true; + } + }; + + blockNode = React.createRef(); + + render() { + const { intl, blockProps, draginfo, extraControls, children } = this.props; + + const { + allowedBlocks, + block, + data, + onDeleteBlock, + onMutateBlock, + selected, + } = blockProps; + const type = data['@type']; + const { disableNewBlocks } = data; + const visible = !data.fixed; + + const required = isBoolean(data.required) + ? data.required + : includes(config.blocks.requiredBlocks, type); + + return ( +
+
+ {!selected && ( +
+ )} + {selected && ( +
+
+ +
+ + {extraControls} + + {!disableNewBlocks && !blockHasValue(data) && ( + + )} + {!required && ( + + )} + {this.state.addNewBlockOpened && ( + { + this.setState({ addNewBlockOpened: false }); + onMutateBlock(id, value); + }} + currentBlock={block} + allowedBlocks={allowedBlocks} + /> + )} +
+ )} + +
+ {children} +
+
+
+ ); + } +} + +export default injectIntl(EditBlockWrapper); diff --git a/src/components/Blocks/Hero/Hero.jsx b/src/components/Blocks/Hero/Hero.jsx index 1f55d8d..dfe6fc6 100644 --- a/src/components/Blocks/Hero/Hero.jsx +++ b/src/components/Blocks/Hero/Hero.jsx @@ -35,6 +35,7 @@ function Hero({ const bgImgRef = React.useRef(); const onScreen = useFirstVisited(bgImgRef); + return (
{ 'hero-block-text', `color-fg-${textVariant}`, `text-${textAlign}`, + quoted ? 'quoted-wrapper' : '', )} > -
{children}
+ {children}
); }; diff --git a/src/components/Blocks/Hero/View.jsx b/src/components/Blocks/Hero/View.jsx index 1c8c3ad..c115c2a 100644 --- a/src/components/Blocks/Hero/View.jsx +++ b/src/components/Blocks/Hero/View.jsx @@ -1,8 +1,9 @@ import React from 'react'; import cx from 'classnames'; import { Icon } from 'semantic-ui-react'; -import { UniversalLink } from '@plone/volto/components'; +import { UniversalLink, RenderBlocks } from '@plone/volto/components'; import { BodyClass } from '@plone/volto/helpers'; +import { useLocation } from 'react-router-dom'; import Hero from './Hero'; import Copyright from './Copyright'; import { serializeText, getFieldURL } from '@eeacms/volto-hero-block/helpers'; @@ -25,14 +26,27 @@ const Metadata = ({ buttonLabel, inverted, styles, ...props }) => { }; const View = (props) => { + const location = useLocation(); const { data = {} } = props; const { text, copyright, copyrightIcon, copyrightPosition } = data; + + const metadata = props.metadata || props.properties; const copyrightPrefix = config.blocks.blocksConfig.hero.copyrightPrefix || ''; return ( - {serializeText(text)} + + {data?.data ? ( + + ) : ( + serializeText(text) + )} + diff --git a/src/components/Blocks/Hero/View.test.jsx b/src/components/Blocks/Hero/View.test.jsx index e70cf5b..f419e64 100644 --- a/src/components/Blocks/Hero/View.test.jsx +++ b/src/components/Blocks/Hero/View.test.jsx @@ -9,7 +9,30 @@ window.IntersectionObserver = jest.fn((callback) => ({ observe, unobserve, })); - +jest.mock('@plone/volto/components', () => { + return { + __esModule: true, + BlocksForm: ({ placeholder, children, onChange, onFocus }) => ( +
+
{placeholder}
+ {children} +
+ ), + SidebarPortal: ({ children }) =>
{children}
, + BlockDataForm: () =>
, + UniversalLink: () =>
, + RenderBlocks: () =>
, + }; +}); +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn().mockReturnValue({ + pathname: '/test-jest', + search: '', + hash: '', + state: null, + key: 'test-jest', + }), +})); describe('View Component', () => { it('renders view component', () => { config.blocks = { diff --git a/src/components/Blocks/Hero/__snapshots__/Hero.test.jsx.snap b/src/components/Blocks/Hero/__snapshots__/Hero.test.jsx.snap index 7fdb7a8..68bfe41 100644 --- a/src/components/Blocks/Hero/__snapshots__/Hero.test.jsx.snap +++ b/src/components/Blocks/Hero/__snapshots__/Hero.test.jsx.snap @@ -22,11 +22,7 @@ exports[`Hero block renders a hero component 1`] = `
-
- Text test -
+ Text test
-
- Text test -
+ Text test
-
- Text test -
+ Text test
-
- Text test -
+ Text test
-
- Text test -
+ Text test
-
- Text test -
+ Text test
-
- Text test -
+ Text test
{ +const applyConfig = (config) => { config.blocks.blocksConfig.hero = { id: 'hero', title: 'Hero', @@ -50,3 +50,5 @@ export default (config) => { return config; }; + +export default applyConfig; diff --git a/src/components/Blocks/Hero/schema.js b/src/components/Blocks/Hero/schema.js index 077ec2c..a0614ff 100644 --- a/src/components/Blocks/Hero/schema.js +++ b/src/components/Blocks/Hero/schema.js @@ -26,7 +26,6 @@ export const HeroBlockSchema = () => { 'quoted', 'spaced', 'inverted', - 'isMultiline', 'buttonLabel', 'buttonLink', 'overlay', @@ -65,11 +64,6 @@ export const HeroBlockSchema = () => { type: 'boolean', defaultValue: true, }, - isMultiline: { - title: 'Multiline', - type: 'boolean', - defaultValue: false, - }, buttonLabel: { title: 'Button label', widget: 'textarea',