diff --git a/docs/data/data-core-editor.md b/docs/data/data-core-editor.md
index 405022aafd29e2..88aca631cf1fd5 100644
--- a/docs/data/data-core-editor.md
+++ b/docs/data/data-core-editor.md
@@ -321,6 +321,24 @@ unsaved status values.
Whether the post has been published.
+### isEditedPostDateFloating
+
+Returns whether the current post should be considered to have a "floating"
+date (i.e. that it would publish "Immediately" rather than at a set time).
+
+Unlike in the PHP backend, the REST API returns a full date string for posts
+where the 0000-00-00T00:00:00 placeholder is present in the database. To
+infer that a post is set to publish "Immediately" we check whether the date
+and modified date are the same.
+
+*Parameters*
+
+ * state: Editor state.
+
+*Returns*
+
+Whether the edited post has a floating date value.
+
### getBlockDependantsCacheBust
Returns a new reference when the inner blocks of a given block client ID
@@ -1241,6 +1259,54 @@ Returns the editor settings.
The editor settings object
+### isPostLocked
+
+Returns whether the post is locked.
+
+*Parameters*
+
+ * state: Global application state.
+
+*Returns*
+
+Is locked.
+
+### isPostLockTakeover
+
+Returns whether the edition of the post has been taken over.
+
+*Parameters*
+
+ * state: Global application state.
+
+*Returns*
+
+Is post lock takeover.
+
+### getPostLockUser
+
+Returns details about the post lock user.
+
+*Parameters*
+
+ * state: Global application state.
+
+*Returns*
+
+A user object.
+
+### getActivePostLock
+
+Returns the active post lock.
+
+*Parameters*
+
+ * state: Global application state.
+
+*Returns*
+
+The lock object.
+
### canUserUseUnfilteredHTML
Returns whether or not the user has the unfiltered_html capability.
@@ -1554,6 +1620,14 @@ Returns an action object used to remove a notice.
* id: The notice id.
+### updatePostLock
+
+Returns an action object used to lock the editor.
+
+*Parameters*
+
+ * lock: Details about the post lock status, user, and nonce.
+
### fetchReusableBlocks
Returns an action object used to fetch a single reusable block or all
diff --git a/edit-post/assets/stylesheets/main.scss b/edit-post/assets/stylesheets/main.scss
index 25c8439adf54c1..dc6a1dfb6c36f0 100644
--- a/edit-post/assets/stylesheets/main.scss
+++ b/edit-post/assets/stylesheets/main.scss
@@ -86,7 +86,9 @@ body.gutenberg-editor-page {
}
}
-.gutenberg {
+.gutenberg,
+// The modals are shown outside the .gutenberg wrapper, they need these styles
+.components-modal__frame {
box-sizing: border-box;
*,
diff --git a/edit-post/editor.js b/edit-post/editor.js
index 1ed71dacf372e6..35680ab884baf8 100644
--- a/edit-post/editor.js
+++ b/edit-post/editor.js
@@ -2,9 +2,8 @@
* WordPress dependencies
*/
import { withSelect } from '@wordpress/data';
-import { EditorProvider, ErrorBoundary } from '@wordpress/editor';
+import { EditorProvider, ErrorBoundary, PostLockedModal } from '@wordpress/editor';
import { StrictMode } from '@wordpress/element';
-
/**
* Internal dependencies
*/
@@ -39,6 +38,7 @@ function Editor( {
+
);
diff --git a/lib/client-assets.php b/lib/client-assets.php
index 7db9f9fb19f6cd..30ce013de655dd 100644
--- a/lib/client-assets.php
+++ b/lib/client-assets.php
@@ -593,6 +593,7 @@ function gutenberg_register_scripts_and_styles() {
'wp-editor',
gutenberg_url( 'build/editor/index.js' ),
array(
+ 'jquery',
'lodash',
'tinymce-latest-lists',
'wp-a11y',
@@ -1459,6 +1460,52 @@ function gutenberg_editor_scripts_and_styles( $hook ) {
}
}
+ // Lock settings.
+ $user_id = wp_check_post_lock( $post->ID );
+ if ( $user_id ) {
+ /**
+ * Filters whether to show the post locked dialog.
+ *
+ * Returning a falsey value to the filter will short-circuit displaying the dialog.
+ *
+ * @since 3.6.0
+ *
+ * @param bool $display Whether to display the dialog. Default true.
+ * @param WP_Post $post Post object.
+ * @param WP_User|bool $user The user id currently editing the post.
+ */
+ if ( apply_filters( 'show_post_locked_dialog', true, $post, $user_id ) ) {
+ $locked = true;
+ }
+
+ $user_details = null;
+ if ( $locked ) {
+ $user = get_userdata( $user_id );
+ $user_details = array(
+ 'name' => $user->display_name,
+ );
+ $avatar = get_avatar( $user_id, 64 );
+ if ( $avatar ) {
+ if ( preg_match( "|src='([^']+)'|", $avatar, $matches ) ) {
+ $user_details['avatar'] = $matches[1];
+ }
+ }
+ }
+
+ $lock_details = array(
+ 'isLocked' => $locked,
+ 'user' => $user_details,
+ );
+ } else {
+
+ // Lock the post.
+ $active_post_lock = wp_set_post_lock( $post->ID );
+ $lock_details = array(
+ 'isLocked' => false,
+ 'activePostLock' => esc_attr( implode( ':', $active_post_lock ) ),
+ );
+ }
+
$editor_settings = array(
'alignWide' => $align_wide || ! empty( $gutenberg_theme_support[0]['wide-images'] ), // Backcompat. Use `align-wide` outside of `gutenberg` array.
'availableTemplates' => $available_templates,
@@ -1471,7 +1518,14 @@ function gutenberg_editor_scripts_and_styles( $hook ) {
'autosaveInterval' => 10,
'maxUploadFileSize' => $max_upload_size,
'allowedMimeTypes' => get_allowed_mime_types(),
- 'styles' => $styles,
+ 'postLock' => $lock_details,
+
+ // Ideally, we'd remove this and rely on a REST API endpoint.
+ 'postLockUtils' => array(
+ 'nonce' => wp_create_nonce( 'lock-post_' . $post->ID ),
+ 'unlockNonce' => wp_create_nonce( 'update-post_' . $post->ID ),
+ 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
+ ),
);
$post_autosave = get_autosave_newer_than_post_save( $post );
diff --git a/package-lock.json b/package-lock.json
index 5b7aecbd715ecd..15e60110a767bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20606,7 +20606,7 @@
},
"chalk": {
"version": "1.1.3",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"dev": true,
"requires": {
diff --git a/packages/components/src/modal/README.md b/packages/components/src/modal/README.md
index 09de5d1bef6627..13667139f8fec1 100644
--- a/packages/components/src/modal/README.md
+++ b/packages/components/src/modal/README.md
@@ -24,7 +24,7 @@ const MyModal = withState( {
-
+
: null }
) );
@@ -77,15 +77,15 @@ If this property is added, it will be added to the modal content `div` as `aria-
If this property is true, it will focus the first tabbable element rendered in the modal.
-- Type: `bool`
+- Type: `boolean`
- Required: No
- Default: true
### shouldCloseOnEsc
-If this property is added, it will determine whether the modal requests to close when the escape key is pressed.
+If this property is added, it will determine whether the modal requests to close when the escape key is pressed.
-- Type: `bool`
+- Type: `boolean`
- Required: No
- Default: true
@@ -93,7 +93,15 @@ If this property is added, it will determine whether the modal requests to close
If this property is added, it will determine whether the modal requests to close when a mouse click occurs outside of the modal content.
-- Type: `bool`
+- Type: `boolean`
+- Required: No
+- Default: true
+
+### isDismissable
+
+If this property is set to false, the modal will not display a close icon and cannot be dismissed.
+
+- Type: `boolean`
- Required: No
- Default: true
diff --git a/packages/components/src/modal/header.js b/packages/components/src/modal/header.js
index c9710d810bb3b2..7ce8b7473e89c7 100644
--- a/packages/components/src/modal/header.js
+++ b/packages/components/src/modal/header.js
@@ -8,7 +8,7 @@ import { __ } from '@wordpress/i18n';
*/
import IconButton from '../icon-button';
-const ModalHeader = ( { icon, title, onClose, closeLabel, headingId } ) => {
+const ModalHeader = ( { icon, title, onClose, closeLabel, headingId, isDismissable } ) => {
const label = closeLabel ? closeLabel : __( 'Close dialog' );
return (
@@ -28,11 +28,13 @@ const ModalHeader = ( { icon, title, onClose, closeLabel, headingId } ) => {
}
-
+ { isDismissable &&
+
+ }
);
};
diff --git a/packages/components/src/modal/index.js b/packages/components/src/modal/index.js
index ab9c5490928c8c..5be41267ac645f 100644
--- a/packages/components/src/modal/index.js
+++ b/packages/components/src/modal/index.js
@@ -126,6 +126,7 @@ class Modal extends Component {
children,
aria,
instanceId,
+ isDismissable,
...otherProps
} = this.props;
@@ -154,10 +155,12 @@ class Modal extends Component {
{ ...otherProps } >
+ icon={ icon }
+ />
{ children }
@@ -178,6 +181,7 @@ Modal.defaultProps = {
focusOnMount: true,
shouldCloseOnEsc: true,
shouldCloseOnClickOutside: true,
+ isDismissable: true,
/* accessibility */
aria: {
labelledby: null,
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 89b0d83f02ad8d..7273643a106df9 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -46,6 +46,7 @@
"classnames": "^2.2.5",
"dom-scroll-into-view": "^1.2.1",
"inherits": "^2.0.3",
+ "jquery": "^3.3.1",
"lodash": "^4.17.10",
"memize": "^1.0.5",
"react-autosize-textarea": "^3.0.2",
diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js
index 5505e6f67f6e84..eb7bc746e6aa47 100644
--- a/packages/editor/src/components/index.js
+++ b/packages/editor/src/components/index.js
@@ -48,6 +48,7 @@ export { default as PostFormat } from './post-format';
export { default as PostFormatCheck } from './post-format/check';
export { default as PostLastRevision } from './post-last-revision';
export { default as PostLastRevisionCheck } from './post-last-revision/check';
+export { default as PostLockedModal } from './post-locked-modal';
export { default as PostPendingStatus } from './post-pending-status';
export { default as PostPendingStatusCheck } from './post-pending-status/check';
export { default as PostPingbacks } from './post-pingbacks';
diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js
new file mode 100644
index 00000000000000..112dca7689e55e
--- /dev/null
+++ b/packages/editor/src/components/post-locked-modal/index.js
@@ -0,0 +1,232 @@
+/**
+ * External dependencies
+ */
+import jQuery from 'jquery';
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Modal, Button } from '@wordpress/components';
+import { withSelect, withDispatch } from '@wordpress/data';
+import { addQueryArgs } from '@wordpress/url';
+import { Component } from '@wordpress/element';
+import { compose, withGlobalEvents } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import { getWPAdminURL } from '../../utils/url';
+import PostPreviewButton from '../post-preview-button';
+
+class PostLockedModal extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.sendPostLock = this.sendPostLock.bind( this );
+ this.receivePostLock = this.receivePostLock.bind( this );
+ this.releasePostLock = this.releasePostLock.bind( this );
+ }
+
+ componentDidMount() {
+ // Details on these events on the Heartbeat API docs
+ // https://developer.wordpress.org/plugins/javascript/heartbeat-api/
+ jQuery( document )
+ .on( 'heartbeat-send.refresh-lock', this.sendPostLock )
+ .on( 'heartbeat-tick.refresh-lock', this.receivePostLock );
+ }
+
+ componentWillUnmount() {
+ jQuery( document )
+ .off( 'heartbeat-send.refresh-lock', this.sendPostLock )
+ .off( 'heartbeat-tick.refresh-lock', this.receivePostLock );
+ }
+
+ /**
+ * Keep the lock refreshed.
+ *
+ * When the user does not send a heartbeat in a heartbeat-tick
+ * the user is no longer editing and another user can start editing.
+ *
+ * @param {Object} event Event.
+ * @param {Object} data Data to send in the heartbeat request.
+ */
+ sendPostLock( event, data ) {
+ const { isLocked, activePostLock, postId } = this.props;
+ if ( isLocked ) {
+ return;
+ }
+
+ data[ 'wp-refresh-post-lock' ] = {
+ lock: activePostLock,
+ post_id: postId,
+ };
+ }
+
+ /**
+ * Refresh post locks: update the lock string or show the dialog if somebody has taken over editing.
+ *
+ * @param {Object} event Event.
+ * @param {Object} data Data received in the heartbeat request
+ */
+ receivePostLock( event, data ) {
+ if ( ! data[ 'wp-refresh-post-lock' ] ) {
+ return;
+ }
+
+ const { autosave, updatePostLock } = this.props;
+ const received = data[ 'wp-refresh-post-lock' ];
+ if ( received.lock_error ) {
+ // Auto save and display the takeover modal.
+ autosave();
+ updatePostLock( {
+ isLocked: true,
+ isTakeover: true,
+ user: {
+ avatar: received.lock_error.avatar_src,
+ },
+ } );
+ } else if ( received.new_lock ) {
+ updatePostLock( {
+ isLocked: false,
+ activePostLock: received.new_lock,
+ } );
+ }
+ }
+
+ /**
+ * Unlock the post before the window is exited.
+ */
+ releasePostLock() {
+ const { isLocked, activePostLock, postLockUtils, postId } = this.props;
+ if ( isLocked || ! activePostLock ) {
+ return;
+ }
+
+ const data = {
+ action: 'wp-remove-post-lock',
+ _wpnonce: postLockUtils.unlockNonce,
+ post_ID: postId,
+ active_post_lock: activePostLock,
+ };
+
+ jQuery.post( {
+ async: false,
+ url: postLockUtils.ajaxUrl,
+ data,
+ } );
+ }
+
+ render() {
+ const { user, postId, isLocked, isTakeover, postLockUtils } = this.props;
+ if ( ! isLocked ) {
+ return null;
+ }
+
+ const userDisplayName = user.name;
+ const userAvatar = user.avatar;
+
+ const unlockUrl = addQueryArgs( 'post.php', {
+ 'get-post-lock': '1',
+ lockKey: true,
+ post: postId,
+ action: 'edit',
+ _wpnonce: postLockUtils.nonce,
+ } );
+ const allPosts = getWPAdminURL( 'edit.php' );
+ return (
+
+ { !! userAvatar && (
+
+ ) }
+ { !! isTakeover && (
+
+
+ { userDisplayName ?
+ sprintf(
+ /* translators: 'post' is generic and may be of any type (post, page, etc.). */
+ __( '%s now has editing control of this post. Don\'t worry, your changes up to this moment have been saved' ),
+ userDisplayName
+ ) :
+ /* translators: 'post' is generic and may be of any type (post, page, etc.). */
+ __( 'Another user now has editing control of this post. Don\'t worry, your changes up to this moment have been saved' )
+ }
+
+ { userDisplayName ?
+ sprintf(
+ /* translators: 'post' is generic and may be of any type (post, page, etc.). */
+ __( '%s is currently working on this post, which means you cannot make changes, unless you take over.' ),
+ userDisplayName
+ ) :
+ /* translators: 'post' is generic and may be of any type (post, page, etc.). */
+ __( 'Another user is currently working on this post, which means you cannot make changes, unless you take over.' )
+ }
+
+
+
+
+
+
+
+
+ ) }
+
+ );
+ }
+}
+
+export default compose(
+ withSelect( ( select ) => {
+ const {
+ getEditorSettings,
+ isPostLocked,
+ isPostLockTakeover,
+ getPostLockUser,
+ getCurrentPostId,
+ getActivePostLock,
+ } = select( 'core/editor' );
+ return {
+ isLocked: isPostLocked(),
+ isTakeover: isPostLockTakeover(),
+ user: getPostLockUser(),
+ postId: getCurrentPostId(),
+ postLockUtils: getEditorSettings().postLockUtils,
+ activePostLock: getActivePostLock(),
+ };
+ } ),
+ withDispatch( ( dispatch ) => {
+ const { autosave, updatePostLock } = dispatch( 'core/editor' );
+ return {
+ autosave,
+ updatePostLock,
+ };
+ } ),
+ withGlobalEvents( {
+ beforeunload: 'releasePostLock',
+ } )
+)( PostLockedModal );
diff --git a/packages/editor/src/components/post-locked-modal/style.scss b/packages/editor/src/components/post-locked-modal/style.scss
new file mode 100644
index 00000000000000..1de50edcd63f48
--- /dev/null
+++ b/packages/editor/src/components/post-locked-modal/style.scss
@@ -0,0 +1,29 @@
+.editor-post-locked-modal {
+ height: auto;
+ padding-right: 10px;
+ padding-left: 10px;
+ padding-top: 10px;
+ max-width: 480px;
+
+ .components-modal__header {
+ height: 36px;
+ }
+
+ .components-modal__content {
+ height: auto;
+ }
+}
+
+.editor-post-locked-modal__buttons {
+ margin-top: 10px;
+
+ .components-button {
+ margin-right: 5px;
+ }
+}
+
+.editor-post-locked-modal__avatar {
+ float: left;
+ margin: 5px;
+ margin-right: 15px;
+}
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 8fda6d17c62524..e238b74d7ad189 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -23,6 +23,7 @@ class EditorProvider extends Component {
// Assume that we don't need to initialize in the case of an error recovery.
if ( ! props.recovery ) {
this.props.updateEditorSettings( props.settings );
+ this.props.updatePostLock( props.settings.postLock );
this.props.setupEditor( props.post, props.settings.autosave );
}
}
@@ -90,9 +91,11 @@ export default withDispatch( ( dispatch ) => {
const {
setupEditor,
updateEditorSettings,
+ updatePostLock,
} = dispatch( 'core/editor' );
return {
setupEditor,
updateEditorSettings,
+ updatePostLock,
};
} )( EditorProvider );
diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js
index 9d74b5e57160db..f8eb4cdb36ec8b 100644
--- a/packages/editor/src/store/actions.js
+++ b/packages/editor/src/store/actions.js
@@ -599,6 +599,20 @@ export function removeNotice( id ) {
};
}
+/**
+ * Returns an action object used to lock the editor.
+ *
+ * @param {Object} lock Details about the post lock status, user, and nonce.
+ *
+ * @return {Object} Action object.
+ */
+export function updatePostLock( lock ) {
+ return {
+ type: 'UPDATE_POST_LOCK',
+ lock,
+ };
+}
+
export const createSuccessNotice = partial( createNotice, 'success' );
export const createInfoNotice = partial( createNotice, 'info' );
export const createErrorNotice = partial( createNotice, 'error' );
diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js
index fbca6f70ad976d..1bcfae88dab7bf 100644
--- a/packages/editor/src/store/reducer.js
+++ b/packages/editor/src/store/reducer.js
@@ -885,6 +885,34 @@ export function notices( state = [], action ) {
return state;
}
+/**
+ * Post Lock State.
+ *
+ * @typedef {Object} PostLockState
+ *
+ * @property {boolean} isLocked Whether the post is locked.
+ * @property {?boolean} isTakeover Whether the post editing has been taken over.
+ * @property {?boolean} activePostLock Active post lock value.
+ * @property {?Object} user User that took over the post.
+ */
+
+/**
+ * Reducer returning the post lock status.
+ *
+ * @param {PostLockState} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {PostLockState} Updated state.
+ */
+export function postLock( state = { isLocked: false }, action ) {
+ switch ( action.type ) {
+ case 'UPDATE_POST_LOCK':
+ return action.lock;
+ }
+
+ return state;
+}
+
export const reusableBlocks = combineReducers( {
data( state = {}, action ) {
switch ( action.type ) {
@@ -1098,6 +1126,7 @@ export default optimist( combineReducers( {
isInsertionPointVisible,
preferences,
saving,
+ postLock,
notices,
reusableBlocks,
template,
diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js
index e41f076476a019..30eee65c1ca77b 100644
--- a/packages/editor/src/store/selectors.js
+++ b/packages/editor/src/store/selectors.js
@@ -1944,6 +1944,50 @@ export function getTokenSettings( state, name ) {
return state.tokens[ name ];
}
+/**
+ * Returns whether the post is locked.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {boolean} Is locked.
+ */
+export function isPostLocked( state ) {
+ return state.postLock.isLocked;
+}
+
+/**
+ * Returns whether the edition of the post has been taken over.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {boolean} Is post lock takeover.
+ */
+export function isPostLockTakeover( state ) {
+ return state.postLock.isTakeover;
+}
+
+/**
+ * Returns details about the post lock user.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Object} A user object.
+ */
+export function getPostLockUser( state ) {
+ return state.postLock.user;
+}
+
+/**
+ * Returns the active post lock.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Object} The lock object.
+ */
+export function getActivePostLock( state ) {
+ return state.postLock.activePostLock;
+}
+
/**
* Returns whether or not the user has the unfiltered_html capability.
*
diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss
index f6e6b8e586e66a..0da9c00f1d8296 100644
--- a/packages/editor/src/style.scss
+++ b/packages/editor/src/style.scss
@@ -27,6 +27,7 @@
@import "./components/post-featured-image/style.scss";
@import "./components/post-format/style.scss";
@import "./components/post-last-revision/style.scss";
+@import "./components/post-locked-modal/style.scss";
@import "./components/post-permalink/style.scss";
@import "./components/post-publish-panel/style.scss";
@import "./components/post-saved-state/style.scss";