From b623d2b2b9490bbe6a6eabc0117bba3e8dabe6a3 Mon Sep 17 00:00:00 2001 From: BE-Webdesign Date: Mon, 28 Aug 2017 17:47:17 -0400 Subject: [PATCH 1/5] Adding initial metabox support. Basic metabox support please see docs/metabox.md for details. --- .eslintignore | 1 + assets/js/meta-box-resize.js | 46 ++ docs/meta-box.md | 134 ++++++ editor/actions.js | 65 +++ editor/effects.js | 7 +- editor/index.js | 13 +- editor/layout/index.js | 6 +- editor/meta-boxes/iframe.js | 256 +++++++++++ editor/meta-boxes/index.js | 84 ++-- editor/meta-boxes/meta-box-iframe.scss | 82 ++++ editor/meta-boxes/panel.js | 62 +++ editor/meta-boxes/style.scss | 62 ++- editor/reducer.js | 58 +++ editor/selectors.js | 54 +++ editor/sidebar/index.js | 3 +- editor/sidebar/post-settings/index.js | 2 + editor/test/actions.js | 47 ++ editor/test/effects.js | 27 ++ editor/test/reducer.js | 107 +++++ editor/test/selectors.js | 276 ++++++++++++ lib/client-assets.php | 11 +- lib/load.php | 1 + lib/meta-box-partial-page.php | 593 +++++++++++++++++++++++++ lib/register.php | 268 ++++++++++- phpunit/class-meta-box-test.php | 207 +++++++++ webpack.config.js | 14 + 26 files changed, 2429 insertions(+), 57 deletions(-) create mode 100644 assets/js/meta-box-resize.js create mode 100644 docs/meta-box.md create mode 100644 editor/meta-boxes/iframe.js create mode 100644 editor/meta-boxes/meta-box-iframe.scss create mode 100644 editor/meta-boxes/panel.js create mode 100644 lib/meta-box-partial-page.php create mode 100644 phpunit/class-meta-box-test.php diff --git a/.eslintignore b/.eslintignore index 164ae5ac2c7ef..3aa71080db424 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ build coverage vendor node_modules +/assets/js diff --git a/assets/js/meta-box-resize.js b/assets/js/meta-box-resize.js new file mode 100644 index 0000000000000..a4c097c804836 --- /dev/null +++ b/assets/js/meta-box-resize.js @@ -0,0 +1,46 @@ +( function() { + var observer; + + if ( ! window.MutationObserver || ! document.getElementById( 'post' ) || ! window.parent ) { + return; + } + + var previousWidth, previousHeight; + + function sendResize() { + var form = document.getElementById( 'post' ); + var location = form.dataset.location; + var newWidth = form.scrollWidth; + var newHeight = form.scrollHeight; + + // Exit early if height has not been impacted. + if ( newWidth === previousWidth && newHeight === previousHeight ) { + return; + } + + window.parent.postMessage( { + action: 'resize', + source: 'meta-box', + location: location, + width: newWidth, + height: newHeight + }, '*' ); + + previousWidth = newWidth; + previousHeight = newHeight; + } + + observer = new MutationObserver( sendResize ); + observer.observe( document.getElementById( 'post' ), { + attributes: true, + attributeOldValue: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true + } ); + + window.addEventListener( 'load', sendResize, true ); + + sendResize(); +} )(); diff --git a/docs/meta-box.md b/docs/meta-box.md new file mode 100644 index 0000000000000..ef43591dcf430 --- /dev/null +++ b/docs/meta-box.md @@ -0,0 +1,134 @@ +# Meta Boxes + +This is a brief document detailing how meta box support works in Gutenberg. With +the superior developer and user experience of blocks however, especially once, +block templates are available, **converting PHP meta boxes to blocks is highly +encouraged!** + +## Breakdown + +Each meta box area is rendered by a React component containing an iframe. +Each iframe will render a partial page containing only meta boxes for that area. +Meta box data is collected and used for conditional rendering. The meta box areas +will appear as toggle-able panels labeled "Extended Settings". More on this in +the MetaBoxIframe component section. + +### Meta Box Data Collection + +On each Gutenberg page load, the global state of post.php is mimicked, this is +hooked in as far back as `plugins_loaded`. + +See `lib/register.php gutenberg_trick_plugins_into_registering_meta_boxes()` + +This will register an action that collects the meta box data to determine if an +area is empty. The original global state is reset upon collection of meta box +data. + +`gutenberg_collect_meta_box_data()` is hooked in later on `admin_head`. It will +run through the functions and hooks that `post.php` runs to register meta boxes; +namely `add_meta_boxes`, `add_meta_boxes_{$post->post_type}`, and `do_meta_boxes`. + +A copy of the global `$wp_meta_boxes` is made then filtered through +`apply_filters( 'filter_gutenberg_meta_boxes', $_meta_boxes_copy );`, which will +strip out any core meta boxes along with standard custom taxonomy meta boxes. + +Then each location for this particular type of meta box is checked for whether it +is active. If it is not empty a value of true is stored, if it is empty a value +of false is stored. This meta box location data is then dispatched by the editor +Redux store in `INITIALIZE_META_BOX_STATE`. + +Ideally, this could be done at instantiation of the editor and help simplify +this flow. However, it is not possible to know the meta box state before +`admin_enqueue_scripts`, where we are calling `createEditorInstance()`. This will +have to do, unless we want to move `createEditorInstance()` to fire in the footer +or at some point after `admin_head`. With recent changes to editor bootstrapping +this might now be possible. Test with ACF to make sure. + +### Redux and React Meta Box Management. + +*The Redux store by default will hold all meta boxes as inactive*. When +`INITIALIZE_META_BOX_STATE` comes in, the store will update any active meta box +areas by setting the `isActive` flag to `true`. Once this happens React will +check for the new props sent in by Redux on the `MetaBox` component. If that +`MetaBox` is now active, instead of rendering null, a `MetaBoxIframe` component will +be rendered. The `MetaBox` component is the container component that mediates +between the `MetaBoxIframe` and the Redux Store. *If no meta boxes are active, +nothing happens. This will be the default behavior, as all core meta boxes have +been stripped.* + +#### MetaBoxIframe Component + +When the component renders it will store a ref to the iframe, the component will +set up a listener for post messages to handle resizing. `assets/js/meta-box-resize.js` is +loaded inside the iframe and will send up postMessages for resizing, which the +`MetaBoxIframe` Component will use to manage its state. A mutation observer will +also be created when the iframe loads. The observer will detect whether, any +DOM changes have happened in the iframe, input and change event listeners will +also be attached to check for changes. + +The change detection will store the current form's `FormData`, then whenever a +change is detected the current form data will be checked vs, the original form +data. This serves as a way to see if the meta box state is dirty. When the +meta box state has been detected to have changed, a Redux action +`META_BOX_STATE_CHANGED` is dispatched, updating the store setting the isDirty +flag to `true`. If the state ever returns back to the original form data, +`META_BOX_STATE_CHANGED` is dispatched again to set the isDirty flag to `false`. +A selector `isMetaBoxStateDirty()` is used to help check whether the post can be +updated. It checks each meta box for whether it is dirty, and if there is at +least one dirty meta box, it will return true. This dirty detection does not +impact creating new posts, as the content will have to change before meta boxes +can trigger the overall dirty state. + +When the post is updated, only meta boxes that are active and dirty, will be +submitted. This removes any unnecessary requests being made. No extra revisions, +are created either by the meta box submissions. A Redux action will trigger on +`REQUEST_POST_UPDATE` for any dirty meta box. See `editor/effects.js`. The +`REQUEST_META_BOX_UPDATES` action will set that meta boxes' state to `isUpdating`, +the `isUpdating` prop will be sent into the `MetaBoxIframe` and cause a form +submission. The iframe will clone itself and perform a double buffer right +before the main iframe submits its data. After loading, the original change +detection process is fired again to handle the new state. + +Since the meta box updating is being triggered on post save success, we check to +see if the post is saving and display an updating overlay, to prevent users from +changing the form values while the meta box is submitting. The saving overlay +could be made transparent, to give a more seamless effect. + +### Iframe serving a partial page. + +Each iframe will point to an individual source. These are partial pages being +served by post.php. Why this approach? By using post.php directly, we don't have +to worry as much about getting the global state 100% correct for each and every +use case of a meta box, especially when it comes to saving. Essentially, when +post.php loads it will set up all of its state correctly, and when it hits the +three `do_action( 'do_meta_boxes' )` hooks it will trigger our partial page. + +When the new block editor was made into the default editor it is now required to +provide the classic-editor flag to access the metabox partial page. + +`gutenberg_meta_box_partial_page()` is used to render the meta boxes for a context +then exit the execution thread early. A `meta_box` request parameter is used to +trigger this early exit. The `meta_box` request parameter should match one of +`'advanced'`, `'normal'`, or `'side'`. This value will determine which meta box +area is served. So an example url would look like: + +`mysite.com/wp-admin/post.php?post=1&action=edit&meta_box=$location&classic-editor` + +This url is automatically passed into React via a `_wpMetaBoxUrl` global variable. +The partial page is very similar to post.php and pretty much imitates it and +after rendering the meta boxes via `do_meta_boxes()` it imitates `admin_footer`, +exits early, and does some hook clean up. There are two extra files that are +enqueued. One is the js file from `assets/js/meta-box-resize.js`, which resizes the iframe. +The other is a stylesheet that is generated by webpack from `editor/meta-boxes/meta-box-iframe.scss` +and built into `editor/build/meta-box-iframe.css` + +These styles make use of some of the SASS variables, so that as the Gutenberg +UI updates so will the meta boxes. + +The partial page mimics the `post.php` post form, so when it is submitted it will +normally fire all of the necessary hooks and actions, and have the proper global +state to correctly fire any PHP meta box mumbo jumbo without needing to modify +any existing code. On successful submission the page will be reloaded back to +the same partial page with updated data. React will signal a `handleMetaBoxReload` +to set up the new form state for dirty checking, remove the updating overlay, +and set the store to no longer be updating the meta box area. diff --git a/editor/actions.js b/editor/actions.js index 1c09a46d38f99..2f40c6a3820a4 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -408,6 +408,71 @@ export function removeNotice( id ) { }; } +/** + * Returns an action object used to check the state of meta boxes at a location. + * + * This should only be fired once to initialize meta box state. If a meta box + * area is empty, this will set the store state to indicate that React should + * not render the meta box area. + * + * Example: metaBoxes = { side: true, normal: false } + * This indicates that the sidebar has a meta box but the normal area does not. + * + * @param {Object} metaBoxes Whether meta box locations are active. + * + * @return {Object} Action object + */ +export function initializeMetaBoxState( metaBoxes ) { + return { + type: 'INITIALIZE_META_BOX_STATE', + metaBoxes, + }; +} + +/** + * Returns an action object used to signify that a meta box finished reloading. + * + * @param {String} location Location of meta box: 'normal', 'side'. + * + * @return {Object} Action object + */ +export function handleMetaBoxReload( location ) { + return { + type: 'HANDLE_META_BOX_RELOAD', + location, + }; +} + +/** + * Returns an action object used to request meta box update. + * + * @param {Array} locations Locations of meta boxes: ['normal', 'side' ]. + * + * @return {Object} Action object + */ +export function requestMetaBoxUpdates( locations ) { + return { + type: 'REQUEST_META_BOX_UPDATES', + locations, + }; +} + +/** + * Returns an action object used to set meta box state changed. + * + * @param {String} location Location of meta box: 'normal', 'side'. + * @param {Boolean} hasChanged Whether the meta box has changed. + * + * @return {Object} Action object + */ +export function metaBoxStateChanged( location, hasChanged ) { + return { + type: 'META_BOX_STATE_CHANGED', + location, + hasChanged, + }; +} + export const createSuccessNotice = partial( createNotice, 'success' ); export const createInfoNotice = partial( createNotice, 'info' ); export const createErrorNotice = partial( createNotice, 'error' ); diff --git a/editor/effects.js b/editor/effects.js index 08485ac6de563..4b117318bd4e0 100644 --- a/editor/effects.js +++ b/editor/effects.js @@ -25,10 +25,12 @@ import { removeNotice, savePost, editPost, + requestMetaBoxUpdates, } from './actions'; import { getCurrentPost, getCurrentPostType, + getDirtyMetaBoxes, getEditedPostContent, getPostEdits, isCurrentPostPublished, @@ -86,7 +88,7 @@ export default { }, REQUEST_POST_UPDATE_SUCCESS( action, store ) { const { previousPost, post } = action; - const { dispatch } = store; + const { dispatch, getState } = store; const publishStatus = [ 'publish', 'private', 'future' ]; const isPublished = publishStatus.indexOf( previousPost.status ) !== -1; @@ -113,6 +115,9 @@ export default { ) ); } + // Update dirty meta boxes. + dispatch( requestMetaBoxUpdates( getDirtyMetaBoxes( getState() ) ) ); + if ( get( window.history.state, 'id' ) !== post.id ) { window.history.replaceState( { id: post.id }, diff --git a/editor/index.js b/editor/index.js index 54d42ba05afde..9977797fef112 100644 --- a/editor/index.js +++ b/editor/index.js @@ -16,6 +16,7 @@ import { settings as dateSettings } from '@wordpress/date'; import './assets/stylesheets/main.scss'; import Layout from './layout'; import EditorProvider from './provider'; +import { initializeMetaBoxState } from './actions'; // Configure moment globally moment.locale( dateSettings.l10n.locale ); @@ -45,17 +46,27 @@ window.jQuery( document ).on( 'heartbeat-tick', ( event, response ) => { /** * Initializes and returns an instance of Editor. * + * The return value of this function is not necessary if we change where we + * call createEditorInstance(). This is due to metaBox timing. + * * @param {String} id Unique identifier for editor instance * @param {Object} post API entity for post to edit * @param {?Object} settings Editor settings object + * @return {Object} Editor interface. Currently supports metabox initialization. */ export function createEditorInstance( id, post, settings ) { const target = document.getElementById( id ); - render( + const provider = render( , target ); + + return { + initializeMetaBoxes( metaBoxes ) { + provider.store.dispatch( initializeMetaBoxState( metaBoxes ) ); + }, + }; } diff --git a/editor/layout/index.js b/editor/layout/index.js index abd11e474cdc9..3f6eb13ded988 100644 --- a/editor/layout/index.js +++ b/editor/layout/index.js @@ -17,11 +17,11 @@ import Header from '../header'; import Sidebar from '../sidebar'; import TextEditor from '../modes/text-editor'; import VisualEditor from '../modes/visual-editor'; -import MetaBoxes from '../meta-boxes'; import UnsavedChangesWarning from '../unsaved-changes-warning'; import DocumentTitle from '../document-title'; import AutosaveMonitor from '../autosave-monitor'; import { removeNotice } from '../actions'; +import MetaBoxes from '../meta-boxes'; import { getEditorMode, isEditorSidebarOpened, @@ -34,7 +34,7 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) { } ); return ( -
+
@@ -45,7 +45,7 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) { { mode === 'text' && } { mode === 'visual' && }
- +
{ isSidebarOpened && } diff --git a/editor/meta-boxes/iframe.js b/editor/meta-boxes/iframe.js new file mode 100644 index 0000000000000..1817f59b26b02 --- /dev/null +++ b/editor/meta-boxes/iframe.js @@ -0,0 +1,256 @@ +/** + * External dependencies + */ +import { isEqual } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import MetaBoxPanel from './panel.js'; + +class MetaBoxIframe extends Component { + constructor() { + super( ...arguments ); + + this.state = { + width: 0, + height: 0, + isOpen: false, + }; + + this.originalFormData = []; + this.hasLoaded = false; + this.formData = []; + this.form = null; + + this.checkMessageForResize = this.checkMessageForResize.bind( this ); + this.handleDoubleBuffering = this.handleDoubleBuffering.bind( this ); + this.handleMetaBoxReload = this.handleMetaBoxReload.bind( this ); + this.checkMetaBoxState = this.checkMetaBoxState.bind( this ); + this.observeChanges = this.observeChanges.bind( this ); + this.bindNode = this.bindNode.bind( this ); + this.isSaving = this.isSaving.bind( this ); + this.toggle = this.toggle.bind( this ); + } + + toggle() { + this.setState( { + isOpen: ! this.state.isOpen, + } ); + } + + bindNode( node ) { + this.node = node; + } + + componentDidMount() { + /** + * Sets up an event listener for resizing. The resizing occurs inside + * the iframe, see gutenberg/assets/js/meta-box-resize.js + */ + window.addEventListener( 'message', this.checkMessageForResize, false ); + + // Initially set node to not display anything so that when it loads, we can see it. + this.node.style.display = 'none'; + + this.node.addEventListener( 'load', this.observeChanges ); + } + + componentWillReceiveProps( nextProps ) { + // Exit early if updating, or not while the post is saving. + if ( ! nextProps.isUpdating || nextProps.isPostSaving ) { + return; + } + + const iframe = this.node; + + this.clonedNode = iframe.cloneNode( true ); + this.clonedNode.classList.add( 'is-updating' ); + this.hideNode( this.clonedNode ); + const parent = iframe.parentNode; + + parent.appendChild( this.clonedNode ); + + /** + * When the dom content has loaded for the cloned iframe handle the + * double buffering. + */ + this.clonedNode.addEventListener( 'load', this.handleDoubleBuffering ); + } + + handleDoubleBuffering() { + const { node, clonedNode, form } = this; + + form.submit(); + + const cloneForm = clonedNode.contentWindow.document.getElementById( 'post' ); + // Make the cloned state match the current state visually. + cloneForm.parentNode.replaceChild( form, cloneForm ); + + this.showNode( clonedNode ); + this.hideNode( node ); + + node.addEventListener( 'load', this.handleMetaBoxReload ); + } + + hideNode( node ) { + node.classList.add( 'is-hidden' ); + } + + showNode( node ) { + node.classList.remove( 'is-hidden' ); + } + + componentWillUnmount() { + const iframe = this.node; + iframe.removeEventListener( 'message', this.checkMessageForResize ); + + if ( this.form ) { + this.form.removeEventListener( 'input', this.checkMetaBoxState ); + this.form.removeEventListener( 'change', this.checkMetaBoxState ); + } + + this.node.removeEventListener( 'load', this.observeChanges ); + } + + observeChanges() { + const node = this.node; + + // The standard post.php form ID post should probably be mimicked. + this.form = this.node.contentWindow.document.getElementById( 'post' ); + + // If the iframe has not already loaded before. + if ( ! this.hasLoaded ) { + node.style.display = 'block'; + this.originalFormData = this.getFormData(); + this.hasLoaded = true; + } + + this.form.addEventListener( 'change', this.checkMetaBoxState ); + this.form.addEventListener( 'input', this.checkMetaBoxState ); + } + + getFormData() { + const form = this.form; + + const data = new window.FormData( form ); + const entries = Array.from( data.entries() ); + return entries; + } + + checkMetaBoxState() { + const { isUpdating, isDirty, changedMetaBoxState, location } = this.props; + + const isStateEqual = isEqual( this.originalFormData, this.getFormData() ); + + /** + * If we are not updating, then if dirty and equal to original, then set not dirty. + * If we are not updating, then if not dirty and not equal to original, set as dirty. + */ + if ( ! isUpdating && ( isDirty === isStateEqual ) ) { + changedMetaBoxState( location, ! isDirty ); + } + } + + handleMetaBoxReload( event ) { + // Remove the reloading event listener once the meta box has loaded. + event.target.removeEventListener( 'load', this.handleMetaBoxReload ); + + if ( this.clonedNode ) { + this.showNode( this.node ); + this.hideNode( this.clonedNode ); + this.clonedNode.removeEventListener( 'load', this.handleDoubleBuffering ); + this.clonedNode.parentNode.removeChild( this.clonedNode ); + delete this.clonedNode; + } + + this.originalFormData = this.getFormData(); + this.props.metaBoxReloaded( this.props.location ); + } + + checkMessageForResize( event ) { + const iframe = this.node; + + // Attempt to parse the message data as JSON if passed as string + let data = event.data || {}; + if ( 'string' === typeof data ) { + try { + data = JSON.parse( data ); + } catch ( e ) {} // eslint-disable-line no-empty + } + + // Check to make sure the meta box matches this location. + if ( data.source !== 'meta-box' || data.location !== this.props.location ) { + return; + } + + // Verify that the mounted element is the source of the message + if ( ! iframe || iframe.contentWindow !== event.source ) { + return; + } + + // Update the state only if the message is formatted as we expect, i.e. + // as an object with a 'resize' action, width, and height + const { action, width, height } = data; + const { width: oldWidth, height: oldHeight } = this.state; + + if ( 'resize' === action && ( oldWidth !== width || oldHeight !== height ) ) { + this.setState( { width, height } ); + } + } + + isSaving() { + const { isUpdating, isDirty, isPostSaving } = this.props; + return isUpdating || ( isDirty && isPostSaving ); + } + + render() { + const { location } = this.props; + const { isOpen, width, height } = this.state; + const isSaving = this.isSaving(); + + const classes = classnames( + 'editor-meta-boxes__iframe', + `is-${ location }`, + { 'is-closed': ! isOpen } + ); + + const overlayClasses = classnames( + 'editor-meta-boxes__loading-overlay', + { 'is-visible': isSaving } + ); + + const iframeClasses = classnames( { 'is-updating': isSaving } ); + + return ( + +
+
+

{ __( 'Updating Settings' ) }

+
+