Skip to content

Commit

Permalink
MetaBoxes: Remove dirty-checking metaboxes state (#4184)
Browse files Browse the repository at this point in the history
* MetaBoxes: Remove dirty-checking metaboxes state
* MetaBoxes: compare meta boxes HTML while leaving the editor to warn about unsaved changes
* Meta Boxes: Changing the way we save metaboxes
Hidden metaboxes (side) were not saved before
  • Loading branch information
youknowriad authored Jan 19, 2018
1 parent 036f60a commit 6dd01de
Show file tree
Hide file tree
Showing 15 changed files with 327 additions and 523 deletions.
8 changes: 3 additions & 5 deletions docs/meta-box.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,9 @@ When rendering the Gutenberg Page, the metaboxes are rendered to a hidden div `#

#### MetaBoxArea Component

When the component renders it will store a ref to the metaboxes container, retrieve the metaboxes HTML from the prefetch location and watches input and changes.
When the component renders it will store a ref to the metaboxes container, retrieve the metaboxes HTML from the prefetch location.

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 areas 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 `MetaBoxArea` and cause a form submission.
When the post is updated, only meta boxes areas that are active 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 active 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 `MetaBoxArea` and cause a form submission.

If the metabox area is saving, we display an updating overlay, to prevent users from changing the form values while the meta box is submitting.

Expand All @@ -75,7 +73,7 @@ So an example url would look like:

This url is automatically passed into React via a `_wpMetaBoxUrl` global variable.

Thus page 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, 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.
Thus page 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, React will signal a `handleMetaBoxReload` to remove the updating overlay, and set the store to no longer be updating the meta box area.


### Common Compatibility Issues
Expand Down
136 changes: 36 additions & 100 deletions editor/components/meta-boxes/meta-boxes-area/index.js
Original file line number Diff line number Diff line change
@@ -1,153 +1,89 @@
/**
* External dependencies
*/
import { isEqual } from 'lodash';
import classnames from 'classnames';
import { connect } from 'react-redux';
import jQuery from 'jquery';

/**
* WordPress dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import { Component } from '@wordpress/element';
import { Spinner } from '@wordpress/components';

/**
* Internal dependencies
*/
import './style.scss';
import { handleMetaBoxReload, metaBoxStateChanged, metaBoxLoaded } from '../../../store/actions';
import { getMetaBox, isSavingPost } from '../../../store/selectors';
import { isSavingMetaBoxes } from '../../../store/selectors';

class MetaBoxesArea extends Component {
/**
* @inheritdoc
*/
constructor() {
super( ...arguments );

this.state = {
loading: false,
};
this.originalFormData = '';
this.bindNode = this.bindNode.bind( this );
this.checkState = this.checkState.bind( this );
}

bindNode( node ) {
this.node = node;
this.bindContainerNode = this.bindContainerNode.bind( this );
}

/**
* @inheritdoc
*/
componentDidMount() {
this.mounted = true;
this.fetchMetaboxes();
}

componentWillUnmount() {
this.mounted = false;
this.unbindFormEvents();
document.querySelector( '#metaboxes' ).appendChild( this.form );
}

unbindFormEvents() {
this.form = document.querySelector( '.metabox-location-' + this.props.location );
if ( this.form ) {
this.form.removeEventListener( 'change', this.checkState );
this.form.removeEventListener( 'input', this.checkState );
this.container.appendChild( this.form );
}
}

componentWillReceiveProps( nextProps ) {
if ( nextProps.isUpdating && ! this.props.isUpdating ) {
this.setState( { loading: true } );
const { location } = nextProps;
const headers = new window.Headers();
const fetchOptions = {
method: 'POST',
headers,
body: new window.FormData( this.form ),
credentials: 'include',
};

// Save the metaboxes
window.fetch( addQueryArgs( window._wpMetaBoxUrl, { meta_box: location } ), fetchOptions )
.then( () => {
if ( ! this.mounted ) {
return false;
}
this.setState( { loading: false } );
this.props.metaBoxReloaded( location );
} );
/**
* Get the meta box location form from the original location.
*/
componentWillUnmount() {
if ( this.form ) {
document.querySelector( '#metaboxes' ).appendChild( this.form );
}
}

fetchMetaboxes() {
const { location } = this.props;
this.form = document.querySelector( '.metabox-location-' + location );
this.node.appendChild( this.form );
this.form.onSubmit = ( event ) => event.preventDefault();
this.originalFormData = this.getFormData();
this.form.addEventListener( 'change', this.checkState );
this.form.addEventListener( 'input', this.checkState );
this.props.metaBoxLoaded( location );
}

getFormData() {
return jQuery( this.form ).serialize();
}

checkState() {
const { loading } = this.state;
const { isDirty, changedMetaBoxState, location } = this.props;

const newIsDirty = ! 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 ( ! loading && isDirty !== newIsDirty ) {
changedMetaBoxState( location, newIsDirty );
}
/**
* Binds the metabox area container node.
*
* @param {Element} node DOM Node.
*/
bindContainerNode( node ) {
this.container = node;
}

/**
* @inheritdoc
*/
render() {
const { location } = this.props;
const { loading } = this.state;
const { location, isSaving } = this.props;

const classes = classnames(
'editor-meta-boxes-area',
`is-${ location }`,
{
'is-loading': loading,
'is-loading': isSaving,
}
);

return (
<div className={ classes }>
{ loading && <Spinner /> }
<div ref={ this.bindNode } />
{ isSaving && <Spinner /> }
<div className="editor-meta-boxes-area__container" ref={ this.bindContainerNode } />
<div className="editor-meta-boxes-area__clear" />
</div>
);
}
}

function mapStateToProps( state, ownProps ) {
const metaBox = getMetaBox( state, ownProps.location );
const { isDirty, isUpdating } = metaBox;

return {
isDirty,
isUpdating,
isPostSaving: isSavingPost( state ) ? true : false,
};
}

function mapDispatchToProps( dispatch ) {
/**
* @inheritdoc
*/
function mapStateToProps( state ) {
return {
// Used to set the reference to the MetaBox in redux, fired when the component mounts.
metaBoxReloaded: ( location ) => dispatch( handleMetaBoxReload( location ) ),
changedMetaBoxState: ( location, hasChanged ) => dispatch( metaBoxStateChanged( location, hasChanged ) ),
metaBoxLoaded: ( location ) => dispatch( metaBoxLoaded( location ) ),
isSaving: isSavingMetaBoxes( state ),
};
}

export default connect( mapStateToProps, mapDispatchToProps )( MetaBoxesArea );
export default connect( mapStateToProps )( MetaBoxesArea );
12 changes: 10 additions & 2 deletions editor/components/post-saved-state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ import {
isEditedPostSaveable,
getCurrentPost,
getEditedPostAttribute,
hasMetaBoxes,
} from '../../store/selectors';

export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSaveable, status, onStatusChange, onSave } ) {
/**
* Component showing whether the post is saved or not and displaying save links.
*
* @param {Object} Props Component Props.
* @returns {WPElement} WordPress Element.
*/
export function PostSavedState( { hasActiveMetaboxes, isNew, isPublished, isDirty, isSaving, isSaveable, status, onStatusChange, onSave } ) {
const className = 'editor-post-saved-state';

if ( isSaving ) {
Expand All @@ -45,7 +52,7 @@ export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSavea
return null;
}

if ( ! isNew && ! isDirty ) {
if ( ! isNew && ! isDirty && ! hasActiveMetaboxes ) {
return (
<span className={ className }>
<Dashicon icon="saved" />
Expand Down Expand Up @@ -79,6 +86,7 @@ export default connect(
isSaving: isSavingPost( state ),
isSaveable: isEditedPostSaveable( state ),
status: getEditedPostAttribute( state, 'status' ),
hasActiveMetaboxes: hasMetaBoxes( state ),
} ),
{
onStatusChange: ( status ) => editPost( { status } ),
Expand Down
30 changes: 28 additions & 2 deletions editor/components/unsaved-changes-warning/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* External dependencies
*/
import { connect } from 'react-redux';
import { some } from 'lodash';
import jQuery from 'jquery';

/**
* WordPress dependencies
Expand All @@ -12,29 +14,52 @@ import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import { isEditedPostDirty } from '../../store/selectors';
import { isEditedPostDirty, getMetaBoxes } from '../../store/selectors';
import { getMetaBoxContainer } from '../../edit-post/meta-boxes';

class UnsavedChangesWarning extends Component {
/**
* @inheritdoc
*/
constructor() {
super( ...arguments );
this.warnIfUnsavedChanges = this.warnIfUnsavedChanges.bind( this );
}

/**
* @inheritdoc
*/
componentDidMount() {
window.addEventListener( 'beforeunload', this.warnIfUnsavedChanges );
}

/**
* @inheritdoc
*/
componentWillUnmount() {
window.removeEventListener( 'beforeunload', this.warnIfUnsavedChanges );
}

/**
* Warns the user if there are unsaved changes before leaving the editor.
*
* @param {Event} event Event Object.
* @returns {string?} Warning message.
*/
warnIfUnsavedChanges( event ) {
if ( this.props.isDirty ) {
const areMetaBoxesDirty = some( this.props.metaBoxes, ( metaBox, location ) => {
return metaBox.isActive &&
jQuery( getMetaBoxContainer( location ) ).serialize() !== metaBox.data;
} );
if ( this.props.isDirty || areMetaBoxesDirty ) {
event.returnValue = __( 'You have unsaved changes. If you proceed, they will be lost.' );
return event.returnValue;
}
}

/**
* @inheritdoc
*/
render() {
return null;
}
Expand All @@ -43,5 +68,6 @@ class UnsavedChangesWarning extends Component {
export default connect(
( state ) => ( {
isDirty: isEditedPostDirty( state ),
metaBoxes: getMetaBoxes( state ),
} )
)( UnsavedChangesWarning );
16 changes: 16 additions & 0 deletions editor/edit-post/meta-boxes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Function returning the current Meta Boxes DOM Node in the editor
* whether the meta box area is opened or not.
* If the MetaBox Area is visible returns it, and returns the original container instead.
*
* @param {string} location Meta Box location.
* @returns {string} HTML content.
*/
export const getMetaBoxContainer = ( location ) => {
const area = document.querySelector( `.editor-meta-boxes-area.is-${ location } .metabox-location-${ location }` );
if ( area ) {
return area;
}

return document.querySelector( '#metaboxes .metabox-location-' + location );
};
Loading

0 comments on commit 6dd01de

Please sign in to comment.