From 8fe8d5b938392357d84507e1fdfb84c7c15d02d0 Mon Sep 17 00:00:00 2001 From: MAX-786 Date: Sat, 15 Jun 2024 22:23:03 +0530 Subject: [PATCH 01/16] send fomData to iframe whenever it is updated and hydra.js will provide onEditChange to subscribe for changes --- hydra.js | 24 ++++++++++++- .../src/components/Iframe/View.jsx | 36 +++++++++++-------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/hydra.js b/hydra.js index 4e754ae..eb506c6 100644 --- a/hydra.js +++ b/hydra.js @@ -29,7 +29,19 @@ class Bridge { } }); } - + onEditChange(initialData, callback) { + window.addEventListener('message', (event) => { + if (event.origin === this.adminOrigin) { + if (event.data.type === 'FORM') { + if (event.data.data) { + callback(event.data.data); + } else { + callback(initialData); + } + } + } + }); + } async get_token() { if (this.token !== null) { return this.token; @@ -130,3 +142,13 @@ export async function getToken() { } return ''; } +/** + * Enable the frontend to listen for changes in the admin and call the callback with updated data + * @param {*} initialData + * @param {*} callback + */ +export function onEditChange(initialData, callback) { + if (bridgeInstance) { + bridgeInstance.onEditChange(initialData, callback); + } +} diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index 6b54beb..bff4a19 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -5,26 +5,22 @@ import Cookies from 'js-cookie'; import './styles.css'; const Iframe = () => { - const [url, setUrl] = useState(''); - - const [src, setSrc] = useState(''); const history = useHistory(); const token = useSelector((state) => state.userSession.token); - + const form = useSelector((state) => state.form.global); const getDefualtUrlFromEnv = () => process.env['RAZZLE_DEFAULT_IFRAME_URL'] || (typeof window !== 'undefined' && window.env['RAZZLE_DEFAULT_IFRAME_URL']); - useEffect(() => { - const defaultUrl = getDefualtUrlFromEnv() || 'http://localhost:3002'; // fallback if env is not set - const savedUrl = Cookies.get('iframe_url'); - const initialUrl = savedUrl - ? `${savedUrl}${window.location.pathname.replace('/edit', '')}` - : `${defaultUrl}${window.location.pathname.replace('/edit', '')}`; - - setUrl(initialUrl); - setSrc(initialUrl); + const defaultUrl = getDefualtUrlFromEnv() || 'http://localhost:3002'; // fallback if env is not set + const savedUrl = Cookies.get('iframe_url'); + const initialUrl = savedUrl + ? `${savedUrl}${history.location.pathname.replace('/edit', '')}` + : `${defaultUrl}${history.location.pathname.replace('/edit', '')}`; + const [url, setUrl] = useState(initialUrl); + const [src, setSrc] = useState(initialUrl); + useEffect(() => { // Listen for messages from the iframe const initialUrlOrigin = new URL(initialUrl).origin; window.addEventListener('message', (event) => { @@ -51,6 +47,16 @@ const Iframe = () => { }); }, [token]); + useEffect(() => { + if (typeof window !== 'undefined') { + // Send the form data to the iframe + const origin = new URL(initialUrl).origin; + document + .getElementById('previewIframe') + .contentWindow.postMessage({ type: 'FORM', data: form }, origin); + } + }, [form]); + const handleUrlChange = (event) => { setUrl(event.target.value); }; @@ -58,8 +64,8 @@ const Iframe = () => { const handleNavigateToUrl = (givenUrl = '') => { // Update adminUI URL with the new URL const formattedUrl = givenUrl ? new URL(givenUrl) : new URL(url); - const newUrl = formattedUrl.href; - setSrc(newUrl); + // const newUrl = formattedUrl.href; + // setSrc(newUrl); const newOrigin = formattedUrl.origin; Cookies.set('iframe_url', newOrigin, { expires: 7 }); From fbd9fb206ab32578a2ffdd5ad45ddf79c5e726b5 Mon Sep 17 00:00:00 2001 From: MAX-786 Date: Sun, 16 Jun 2024 02:45:52 +0530 Subject: [PATCH 02/16] update readme --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3035d9c..56c47d7 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,27 @@ If you wish to make the editing experience faster you can register for ```onSave #### Enable Show changes while editing -You will need to subscribe to an ```onEditChange``` event that will send blocks or metadata changes +You will need to subscribe to an ```onEditChange``` event that will send blocks or metadata changes. -TODO: not implemented yet. +The `onEditChange` method listens for changes in the Hydra and triggers a callback with updated data or the initial data if no update is available. It takes following args: +| Args | Description | +| :-----------:| :-------| +| *initialData*| The initial data to fall back on if no updated data is received. | +| *callback* | A function to call with the updated data when a change is detected. | + +Usage: +```js +// the initial data (from ploneClient) +const initialData = data; + +// Define the callback function +function handleEditChange(updatedData) { + console.log('Updated data:', updatedData); +} + +// Set up the onEditChange listener +onEditChange(initialData, handleEditChange); +``` #### Enable Managing Blocks directly on your frontend From 687cacd18a7e0d1221209c373bc6b13876f3188c Mon Sep 17 00:00:00 2001 From: MAX-786 Date: Mon, 17 Jun 2024 14:30:18 +0530 Subject: [PATCH 03/16] add redux state to store selected block uid --- README.md | 3 + packages/volto-hydra/src/actions.js | 8 + .../src/components/Iframe/View.jsx | 8 +- packages/volto-hydra/src/constants.js | 1 + .../components/manage/Blocks/Block/Edit.jsx | 226 ++++++++++++++++++ .../manage/Blocks/Block/Edit.original.jsx | 222 +++++++++++++++++ .../components/manage/Form/Form.jsx | 7 +- .../components/manage/Form/styles.css | 3 + packages/volto-hydra/src/index.js | 3 + packages/volto-hydra/src/reducers.js | 18 ++ 10 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 packages/volto-hydra/src/actions.js create mode 100644 packages/volto-hydra/src/constants.js create mode 100644 packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx create mode 100644 packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx create mode 100644 packages/volto-hydra/src/customizations/components/manage/Form/styles.css create mode 100644 packages/volto-hydra/src/reducers.js diff --git a/README.md b/README.md index 56c47d7..8155473 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ onEditChange(initialData, handleEditChange); You will add data attributes to your rendered block html so hydra knows where they are on the page and it will automatically handle click events and show a quanta toolbar when selecting a block. +Add the `data-block-uid={}` attribute to your rendered block html and call the `enableBlockClickListener()` provided by 'hydra.js' whenever your component is mounted. +Now, Click on your blocks in iframe and the sidebar will show its settings. + TODO: not implemented yet #### Enable Editing blocks inplace diff --git a/packages/volto-hydra/src/actions.js b/packages/volto-hydra/src/actions.js new file mode 100644 index 0000000..4360841 --- /dev/null +++ b/packages/volto-hydra/src/actions.js @@ -0,0 +1,8 @@ +import { SET_SELECTED_BLOCK } from './constants'; + +export function setSelectedBlock(uid) { + return { + type: SET_SELECTED_BLOCK, + uid: uid, + }; +} diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index bff4a19..8d1b3af 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import Cookies from 'js-cookie'; import './styles.css'; +import { setSelectedBlock } from '../../actions'; const Iframe = () => { + const dispatch = useDispatch(); const history = useHistory(); const token = useSelector((state) => state.userSession.token); const form = useSelector((state) => state.form.global); @@ -41,6 +43,10 @@ const Iframe = () => { ); break; + case 'OPEN_SETTINGS': + dispatch(setSelectedBlock(event.data.uid)); + break; + default: break; } diff --git a/packages/volto-hydra/src/constants.js b/packages/volto-hydra/src/constants.js new file mode 100644 index 0000000..71dfa9e --- /dev/null +++ b/packages/volto-hydra/src/constants.js @@ -0,0 +1 @@ +export const SET_SELECTED_BLOCK = 'SET_SELECTED_BLOCK'; diff --git a/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx new file mode 100644 index 0000000..3608ccd --- /dev/null +++ b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx @@ -0,0 +1,226 @@ +/** + * Edit block. + * @module components/manage/Blocks/Block/Edit + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import cx from 'classnames'; +import { setSidebarTab } from '@plone/volto/actions'; +import config from '@plone/volto/registry'; +import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; +import { applyBlockDefaults } from '@plone/volto/helpers'; +import { ViewDefaultBlock, EditDefaultBlock } from '@plone/volto/components'; + +import { + SidebarPortal, + BlockSettingsSidebar, + BlockSettingsSchema, +} from '@plone/volto/components'; + +const messages = defineMessages({ + unknownBlock: { + id: 'Unknown Block', + defaultMessage: 'Unknown Block {block}', + }, +}); + +/** + * Edit block class. + * @class Edit + * @extends Component + */ +export class Edit extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + type: PropTypes.string.isRequired, + data: PropTypes.objectOf(PropTypes.any).isRequired, + // properties is mapped to formData, so it's not connected to changes of the object + properties: PropTypes.objectOf(PropTypes.any).isRequired, + selected: PropTypes.bool.isRequired, + multiSelected: PropTypes.bool, + index: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + manage: PropTypes.bool, + onMoveBlock: PropTypes.func.isRequired, + onDeleteBlock: PropTypes.func.isRequired, + editable: PropTypes.bool, + pathname: PropTypes.string.isRequired, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + manage: false, + editable: true, + }; + componentDidMount() { + const { type } = this.props; + const { blocksConfig = config.blocks.blocksConfig } = this.props; + + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + this.props.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + const tab = this.props.manage ? 1 : blocksConfig?.[type]?.sidebarTab || 0; + if (this.props.selected && this.props.editable) { + this.props.setSidebarTab(tab); + } + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { selected, type } = this.props; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + nextProps.selected && + selected !== nextProps.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + if ( + ((!this.props.selected && nextProps.selected) || + type !== nextProps.type) && + this.props.editable + ) { + const tab = this.props.manage + ? 1 + : blocksConfig?.[nextProps.type]?.sidebarTab || 0; + this.props.setSidebarTab(tab); + } + } + + blockNode = React.createRef(); + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { editable, type } = this.props; + + const disableNewBlocks = this.props.data?.disableNewBlocks; + + let Block = blocksConfig?.[type]?.['edit'] || EditDefaultBlock; + if ( + this.props.data?.readOnly || + (!editable && !config.blocks.showEditBlocksInBabelView) + ) { + Block = blocksConfig?.[type]?.['view'] || ViewDefaultBlock; + } + const schema = blocksConfig?.[type]?.['schema'] || BlockSettingsSchema; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + + return ( + <> + {Block !== null ? ( +
{ + const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; + !this.props.selected && + this.props.onSelectBlock( + this.props.id, + this.props.selected ? false : isMultipleSelection, + e, + ); + }} + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx('block', type, this.props.data.variation, { + selected: this.props.selected || this.props.multiSelected, + multiSelected: this.props.multiSelected, + })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + tabIndex={!blockHasOwnFocusManagement ? -1 : null} + > + + {this.props.manage && ( + + + + )} +
+ ) : ( +
+ !this.props.selected && this.props.onSelectBlock(this.props.id) + } + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx(`block ${type}`, { selected: this.props.selected })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + tabIndex={-1} + > + {this.props.intl.formatMessage(messages.unknownBlock, { + block: type, + })} +
+ )} + + ); + } +} + +export default compose( + injectIntl, + withObjectBrowser, + connect( + (state) => ({ + selectedBlock: state.selectedBlock, + }), + { setSidebarTab }, + ), +)(Edit); diff --git a/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx new file mode 100644 index 0000000..cbcfdf5 --- /dev/null +++ b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx @@ -0,0 +1,222 @@ +/** + * Edit block. + * @module components/manage/Blocks/Block/Edit + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import cx from 'classnames'; +import { setSidebarTab } from '@plone/volto/actions'; +import config from '@plone/volto/registry'; +import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; +import { applyBlockDefaults } from '@plone/volto/helpers'; +import { ViewDefaultBlock, EditDefaultBlock } from '@plone/volto/components'; + +import { + SidebarPortal, + BlockSettingsSidebar, + BlockSettingsSchema, +} from '@plone/volto/components'; + +const messages = defineMessages({ + unknownBlock: { + id: 'Unknown Block', + defaultMessage: 'Unknown Block {block}', + }, +}); + +/** + * Edit block class. + * @class Edit + * @extends Component + */ +export class Edit extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + type: PropTypes.string.isRequired, + data: PropTypes.objectOf(PropTypes.any).isRequired, + // properties is mapped to formData, so it's not connected to changes of the object + properties: PropTypes.objectOf(PropTypes.any).isRequired, + selected: PropTypes.bool.isRequired, + multiSelected: PropTypes.bool, + index: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + manage: PropTypes.bool, + onMoveBlock: PropTypes.func.isRequired, + onDeleteBlock: PropTypes.func.isRequired, + editable: PropTypes.bool, + pathname: PropTypes.string.isRequired, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + manage: false, + editable: true, + }; + + componentDidMount() { + const { type } = this.props; + const { blocksConfig = config.blocks.blocksConfig } = this.props; + + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + this.props.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + const tab = this.props.manage ? 1 : blocksConfig?.[type]?.sidebarTab || 0; + if (this.props.selected && this.props.editable) { + this.props.setSidebarTab(tab); + } + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { selected, type } = this.props; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + nextProps.selected && + selected !== nextProps.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + if ( + ((!this.props.selected && nextProps.selected) || + type !== nextProps.type) && + this.props.editable + ) { + const tab = this.props.manage + ? 1 + : blocksConfig?.[nextProps.type]?.sidebarTab || 0; + this.props.setSidebarTab(tab); + } + } + + blockNode = React.createRef(); + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { editable, type } = this.props; + + const disableNewBlocks = this.props.data?.disableNewBlocks; + + let Block = blocksConfig?.[type]?.['edit'] || EditDefaultBlock; + if ( + this.props.data?.readOnly || + (!editable && !config.blocks.showEditBlocksInBabelView) + ) { + Block = blocksConfig?.[type]?.['view'] || ViewDefaultBlock; + } + const schema = blocksConfig?.[type]?.['schema'] || BlockSettingsSchema; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + + return ( + <> + {Block !== null ? ( +
{ + const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; + !this.props.selected && + this.props.onSelectBlock( + this.props.id, + this.props.selected ? false : isMultipleSelection, + e, + ); + }} + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx('block', type, this.props.data.variation, { + selected: this.props.selected || this.props.multiSelected, + multiSelected: this.props.multiSelected, + })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + tabIndex={!blockHasOwnFocusManagement ? -1 : null} + > + + {this.props.manage && ( + + + + )} +
+ ) : ( +
+ !this.props.selected && this.props.onSelectBlock(this.props.id) + } + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx(`block ${type}`, { selected: this.props.selected })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + tabIndex={-1} + > + {this.props.intl.formatMessage(messages.unknownBlock, { + block: type, + })} +
+ )} + + ); + } +} + +export default compose( + injectIntl, + withObjectBrowser, + connect(null, { setSidebarTab }), +)(Edit); diff --git a/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx b/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx index 3b7668e..f14a401 100644 --- a/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx +++ b/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx @@ -57,7 +57,7 @@ import { compose } from 'redux'; import config from '@plone/volto/registry'; import SlotRenderer from '@plone/volto/components/theme/SlotRenderer/SlotRenderer'; import Iframe from '../../../../components/Iframe/View'; - +import './styles.css'; /** * Form container class. * @class Form @@ -700,7 +700,8 @@ class Form extends Component { }} />