Skip to content

Commit

Permalink
Adding initial metabox support.
Browse files Browse the repository at this point in the history
Basic metabox support please see docs/metabox.md for details.
  • Loading branch information
BE-Webdesign committed Sep 24, 2017
1 parent 26369c3 commit bc1046f
Show file tree
Hide file tree
Showing 22 changed files with 2,077 additions and 7 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ build
coverage
vendor
node_modules
/assets/js
46 changes: 46 additions & 0 deletions assets/js/metabox.js
Original file line number Diff line number Diff line change
@@ -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: 'metabox',
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();
} )();
130 changes: 130 additions & 0 deletions docs/metabox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Metaboxes

This is a brief document detailing how metabox support works in Gutenberg. With
the superior developer and user experience of blocks however, especially once,
block templates are available, **converting PHP metaboxes to blocks is highly
encouraged!**

## Breakdown

Each metabox area is rendered by a React component containing an iframe.
Each iframe will render a partial page containing only metaboxes for that area.
Metabox data is collected and used for conditional rendering.

### Metabox 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_metaboxes()`

This will register two new actions, one that fakes the global post state, and
one that collects the metabox data to determine if an area is empty.

gutenberg_set_post_state() is hooked in early on admin_head to fake the post
state. This is necessary for ACF to work, no other metabox frameworks seem to
have this problem. ACF will grab the `$post->post_type` to determine whether a
box should be registered. Later in `admin_head` ACF will register the metaboxes.

Hooked in later on admin_head is gutenberg_collect_metabox_data(), this will
run through the functions and hooks that post.php runs to register metaboxes;
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_metaboxes', $_metaboxes_copy );`, which will
strip out any core metaboxes along with standard custom taxonomy metaboxes.

Then each location for this particular type of metabox 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 metabox location data is then dispatched by the editor
Redux store in `INITIALIZE_METABOX_STATE`.

Ideally, this could be done at instantiation of the editor, and help simplify,
this flow. However, it is not possible to know the metabox state before
`admin_enqueue_scripts` where we are calling `createEditorInstance()`. This will
have to do.

### Redux and React Metabox Management.

*The Redux store by default will hold all metaboxes as inactive*. When
`INITIALIZE_METABOX_STATE` comes in, the store will update any active metabox
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 metaboxes are active,
nothing happens. This will be the default behavior, as all core metaboxes 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 for resizing. assets/js/metabox.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 metabox state is dirty. When the
metabox state has been detected to have changed, a Redux action
`METABOX_STATE_CHANGED` is dispatched, updating the store setting the isDirty
flag to `true`. If the state ever returns back to the original form data,
`METABOX_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 metabox for whether it is dirty, and if there is at
least one dirty metabox, it will return true. This dirty detection does not
impact creating new posts, as the content will have to change before metaboxes
can trigger the overall dirty state.

When the post is updated, only metaboxes that are active and dirty, will be
submitted. This removes any unnecessary requests being made. No extra revisions,
are created either by the metabox submissions. A redux action will trigger on
`REQUEST_POST_UPDATE` for any dirty metabox. See editor/effects.js. The
`REQUEST_METABOX_UPDATE` action will set that metabox's state to isUpdating,
the isUpdating prop will be sent into the MetaboxIframe and cause a form
submission. After loading, the original change detection process is fired again
to handle the new state. Double buffering the iframes here will improve the
user experience quite a bit, as there currently is a flicker.

### 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 metabox, 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.

`gutenberg_metabox_partial_page()` is used to render the metaboxes for a context
then exit the execution thread early. A `metabox` request parameter is used to
trigger this early exit. The metabox request parameter should match one of
`'advanced'`, `'normal'`, or `'side'`. This value will determine which metabox
area is served. So an example url would look like:

`mysite.com/wp-admin/post.php?post=1&action=edit&metabox=$location`

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 metaboxes via do_meta_boxes() it exits early and does some
hook clean up. There are two extra files that are enqueued; both with a handle
metabox-gutenberg. One is the js file from assets/js/metabox.js, which resizes
the iframe. The stylesheet is generated by webpack from
editor/metaboxes/metabox-iframe.scss and built
into editor/build/metabox-iframe.css

These styles make use of some of the SASS variables, so that as the Gutenberg
UI updates so will the Metaboxes.

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 metabox 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.

## Wrap Up.

There are some other details I am probably forgetting but this is a pretty good
overview.
66 changes: 66 additions & 0 deletions editor/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,72 @@ export function removeNotice( id ) {
};
}

// Metabox related actions.
/**
* Returns an action object used to check the state of metaboxes at a location.
*
* This should only be fired once to initialize meta box state. If a metabox
* 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 metabox but the normal area does not.
*
* @param {Object} metaboxes Whether metabox locations are active.
*
* @return {Object} Action object
*/
export function initializeMetaboxState( metaboxes ) {
return {
type: 'INITIALIZE_METABOX_STATE',
metaboxes,
};
}

/**
* Returns an action object used to signify that a metabox finished reloading.
*
* @param {String} location Location of metabox: 'normal', 'sidebar'.
*
* @return {Object} Action object
*/
export function handleMetaboxReload( location ) {
return {
type: 'HANDLE_METABOX_RELOAD',
location,
};
}

/**
* Returns an action object used to request metabox update.
*
* @param {String} location Location of metabox: 'normal', 'sidebar'.
*
* @return {Object} Action object
*/
export function requestMetaboxUpdate( location ) {
return {
type: 'REQUEST_METABOX_UPDATE',
location,
};
}

/**
* Returns an action object used to set metabox state changed.
*
* @param {String} location Location of metabox: 'normal', 'sidebar'.
* @param {Boolean} hasChanged Whether the metabox has changed.
*
* @return {Object} Action object
*/
export function metaboxStateChanged( location, hasChanged ) {
return {
type: 'METABOX_STATE_CHANGED',
location,
hasChanged,
};
}

export const createSuccessNotice = partial( createNotice, 'success' );
export const createInfoNotice = partial( createNotice, 'info' );
export const createErrorNotice = partial( createNotice, 'error' );
Expand Down
8 changes: 7 additions & 1 deletion editor/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import {
removeNotice,
savePost,
editPost,
requestMetaboxUpdate,
} from './actions';
import {
getCurrentPost,
getCurrentPostType,
getDirtyMetaboxes,
getEditedPostContent,
getPostEdits,
isCurrentPostPublished,
Expand Down Expand Up @@ -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;
Expand All @@ -113,6 +115,10 @@ export default {
) );
}

// Update dirty metaboxes.
const metaboxes = getDirtyMetaboxes( getState() );
metaboxes.map( metabox => dispatch( requestMetaboxUpdate( metabox ) ) );

if ( get( window.history.state, 'id' ) !== post.id ) {
window.history.replaceState(
{ id: post.id },
Expand Down
12 changes: 12 additions & 0 deletions editor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const DEFAULT_SETTINGS = {
maxWidth: 608,
};

/**
* Sadly we probably can not add this data directly into editor settings.
*
* ACF and other metaboxes need admin_head to fire for metabox registry.
* admin_head fires after admin_enqueue_scripts which is where we create our
* editor instance. If a cleaner solution can be imagined, please change
* this, and try to get this data to load directly into the editor settings.
*/

// Configure moment globally
moment.locale( dateSettings.l10n.locale );
if ( dateSettings.timezone.string ) {
Expand All @@ -63,6 +72,7 @@ if ( dateSettings.timezone.string ) {
* @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} The Redux store of the editor.
*/
export function createEditorInstance( id, post, settings ) {
const store = createReduxStore();
Expand Down Expand Up @@ -149,4 +159,6 @@ export function createEditorInstance( id, post, settings ) {
);

render( createEditorElement( <Layout /> ), target );

return store;
}
10 changes: 6 additions & 4 deletions editor/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import UnsavedChangesWarning from '../unsaved-changes-warning';
import DocumentTitle from '../document-title';
import AutosaveMonitor from '../autosave-monitor';
import { removeNotice } from '../actions';
import Metabox from '../metaboxes';
import {
getEditorMode,
isEditorSidebarOpened,
Expand All @@ -32,8 +33,8 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
'is-sidebar-opened': isSidebarOpened,
} );

return (
<div className={ className }>
return [
<div key="editor" className={ className }>
<DocumentTitle />
<NoticeList onRemove={ props.removeNotice } notices={ notices } />
<UnsavedChangesWarning />
Expand All @@ -44,8 +45,9 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
{ mode === 'visual' && <VisualEditor /> }
</div>
{ isSidebarOpened && <Sidebar /> }
</div>
);
</div>,
<Metabox key="metaboxes" location="normal" isSidebarOpened={ isSidebarOpened } />,
];
}

export default connect(
Expand Down

0 comments on commit bc1046f

Please sign in to comment.