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' ) + } +
+

+ + { __( 'View all posts' ) } + +

+
+ ) } + { ! isTakeover && ( +
+
+ { 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";