diff --git a/src/js/_enqueues/admin/site-editor-post-lock.js b/src/js/_enqueues/admin/site-editor-post-lock.js new file mode 100644 index 0000000000000..cccffe377590d --- /dev/null +++ b/src/js/_enqueues/admin/site-editor-post-lock.js @@ -0,0 +1,132 @@ +/** + * @file Site Editor post lock bridge. + * + * Wires the Site Editor (wp_template, wp_template_part) into the existing + * Heartbeat-based post lock workflow. The bulk of the implementation lives in + * core: wp_refresh_post_lock() handles the server side, and the + * `#post-lock-dialog` markup is rendered by _admin_notice_post_locked() via + * site-editor.php. This file is the JS glue that: + * + * - Tracks the currently edited template entity through the core/edit-site + * data store so client-side navigation between templates is reflected in + * the Heartbeat payload. + * - Emits the same `wp-refresh-post-lock` payload that + * wp-admin/js/post.js sends from the classic editor. + * - Reveals the server-rendered lock dialog on a `lock_error` tick and + * stores the refreshed lock token on a `new_lock` tick. + * + * @output wp-admin/js/site-editor-post-lock.js + */ + +/* global wp */ + +( function ( $, settings ) { + 'use strict'; + + if ( ! settings || ! settings.enabled ) { + return; + } + + var initialPostId = parseInt( settings.postId, 10 ) || 0; + var activePostId = initialPostId; + var activeLock = String( settings.lock || '' ); + + /** + * Subscribe to the core/edit-site data store so the active post ID is + * always kept in sync when the user navigates between templates without + * a full page reload. + */ + if ( wp && wp.data && typeof wp.data.subscribe === 'function' ) { + wp.data.subscribe( function () { + var store = wp.data.select( 'core/edit-site' ); + if ( ! store ) { + return; + } + + var nextType = store.getEditedPostType(); + var nextId = store.getEditedPostId(); + + if ( + ( 'wp_template' === nextType || 'wp_template_part' === nextType ) && + 'number' === typeof nextId && + nextId !== activePostId + ) { + activePostId = nextId; + /* + * The new entity will receive its own lock token via the + * next Heartbeat tick. + */ + activeLock = ''; + } + } ); + } + + $( function () { + var $dialog = $( '#post-lock-dialog' ); + if ( $dialog.length && settings.takeOverUrl ) { + $dialog.find( '.button-primary.wp-tab-last' ).attr( 'href', settings.takeOverUrl ); + } + } ); + + $( document ) + /* + * Send the lock payload on every Heartbeat tick when we have a + * resolved template post. Mirrors `heartbeat-send.refresh-lock` + * in wp-admin/js/post.js. + */ + .on( 'heartbeat-send.refresh-site-editor-post-lock', function ( e, data ) { + if ( ! activePostId ) { + return; + } + + var send = { post_id: activePostId }; + + if ( activeLock ) { + send.lock = activeLock; + } + + data['wp-refresh-post-lock'] = send; + } ) + /* + * Handle the response: surface the server-rendered dialog on + * `lock_error`, or remember the refreshed lock token on + * `new_lock`. + */ + .on( 'heartbeat-tick.refresh-site-editor-post-lock', function ( e, data ) { + if ( ! data['wp-refresh-post-lock'] ) { + return; + } + + var received = data['wp-refresh-post-lock']; + + if ( received.lock_error ) { + var $wrap = $( '#post-lock-dialog' ); + + if ( $wrap.length && ! $wrap.is( ':visible' ) ) { + if ( received.lock_error.avatar_src ) { + var $avatar = $( '', { + 'class': 'avatar avatar-64 photo', + width: 64, + height: 64, + alt: '', + src: received.lock_error.avatar_src, + srcset: received.lock_error.avatar_src_2x ? + received.lock_error.avatar_src_2x + ' 2x' : + undefined + } ); + + $wrap.find( 'div.post-locked-avatar' ).empty().append( $avatar ); + } + + $wrap.removeClass( 'hidden' ) + .show() + .find( '.currently-editing' ).text( received.lock_error.text ); + + $wrap.find( '.wp-tab-first' ).trigger( 'focus' ); + } + } else if ( received.new_lock ) { + activeLock = received.new_lock; + } + } ); + +}( jQuery, window.wpSiteEditorPostLockL10n ) ); diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..c70a9ccb4ea73 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -79,6 +79,10 @@ add_filter( 'heartbeat_received', 'wp_refresh_post_lock', 10, 3 ); add_filter( 'heartbeat_received', 'heartbeat_autosave', 500, 2 ); +// Site Editor post lock acquisition during REST edit-context reads. +add_filter( 'rest_prepare_wp_template', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 ); +add_filter( 'rest_prepare_wp_template_part', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 ); + add_filter( 'wp_refresh_nonces', 'wp_refresh_post_nonces', 10, 3 ); add_filter( 'wp_refresh_nonces', 'wp_refresh_metabox_loader_nonces', 10, 2 ); add_filter( 'wp_refresh_nonces', 'wp_refresh_heartbeat_nonces' ); diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 8d52c1487a7a5..878ba7e28c34a 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -1778,6 +1778,57 @@ function wp_set_post_lock( $post ) { return array( $now, $user_id ); } +/** + * Acquires a Site Editor post lock during REST API edit-context responses. + * + * The classic editor calls {@see wp_set_post_lock()} when an edit screen renders, + * but Site Editor templates ({@see wp_template}, {@see wp_template_part}) are + * loaded over the REST API instead of through `post.php`. This function bridges + * that gap so the existing Heartbeat-based post locking workflow can be reused + * for the Site Editor. + * + * Hooked to `rest_prepare_wp_template` and `rest_prepare_wp_template_part`. The + * lock is only acquired when the request `context` is `edit` and the + * `wp_apply_site_editor_post_lock` filter does not opt out (for example, when a + * real-time collaboration plugin is active). + * + * @since 7.1.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post The post object being prepared. + * @param WP_REST_Request $request The current request. + * @return WP_REST_Response The unchanged response object. + */ +function wp_set_site_editor_post_lock_on_rest_prepare( $response, $post, $request ) { + if ( 'edit' !== $request['context'] ) { + return $response; + } + + if ( empty( $post ) || empty( $post->ID ) ) { + return $response; + } + + /** + * Filters whether the Site Editor post lock should be applied to a template. + * + * Returning false disables both lock acquisition during REST edit-context + * reads and the "Take over" flow on `site-editor.php`. Real-time + * collaboration plugins can short-circuit the entire lock pipeline here. + * + * @since 7.1.0 + * + * @param bool $apply Whether to apply the Site Editor post lock. Default true. + * @param WP_Post $post The template post being prepared. + */ + if ( ! apply_filters( 'wp_apply_site_editor_post_lock', true, $post ) ) { + return $response; + } + + wp_set_post_lock( $post ); + + return $response; +} + /** * Outputs the HTML for the notice to say that someone else is editing or has taken over editing of this post. * diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 9a8268c3392d7..672399879f4a8 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -117,6 +117,86 @@ function _wp_get_site_editor_redirection_url() { exit; } +/* + * Resolve the template post being edited, if any. + * + * Site Editor URLs use the canonical `?p=/{post_type}/{post_id}` form once the + * redirection block above has run. Theme-supplied templates that have not been + * persisted yet (no numeric ID) are intentionally not resolved here; locking + * starts at the point a real WP_Post exists. + */ +$site_editor_post = null; +$site_editor_post_candidate = null; + +if ( ! empty( $_GET['postId'] ) && is_numeric( $_GET['postId'] ) ) { + $site_editor_post_candidate = get_post( (int) $_GET['postId'] ); +} elseif ( isset( $_GET['p'] ) && preg_match( '#^/(wp_template|wp_template_part)/(\d+)$#', $_GET['p'], $site_editor_post_matches ) ) { + $site_editor_post_candidate = get_post( (int) $site_editor_post_matches[2] ); +} + +if ( + $site_editor_post_candidate instanceof WP_Post && + in_array( $site_editor_post_candidate->post_type, array( 'wp_template', 'wp_template_part' ), true ) +) { + $site_editor_post = $site_editor_post_candidate; +} + +unset( $site_editor_post_candidate, $site_editor_post_matches ); + +/* + * Determine whether locking applies to the resolved template. Computed once so + * the take-over handler, lock acquisition, and dialog hook below all share a + * single filter dispatch. + */ +$site_editor_post_lock_enabled = false; + +if ( $site_editor_post ) { + /** This filter is documented in wp-admin/includes/post.php */ + $site_editor_post_lock_enabled = (bool) apply_filters( 'wp_apply_site_editor_post_lock', true, $site_editor_post ); +} + +// Handle "Take over" requests from the Site Editor post lock dialog. +if ( $site_editor_post && ! empty( $_GET['get-post-lock'] ) ) { + check_admin_referer( 'lock-post_' . $site_editor_post->ID ); + + if ( $site_editor_post_lock_enabled ) { + wp_set_post_lock( $site_editor_post->ID ); + } + + wp_safe_redirect( remove_query_arg( array( 'get-post-lock', '_wpnonce' ) ) ); + exit; +} + +/* + * Acquire (or refresh) the post lock for the current user when no other user + * holds it. Mirrors the wp-admin/post.php:191-192 pattern. + */ +$site_editor_active_post_lock = null; + +if ( $site_editor_post_lock_enabled && ! wp_check_post_lock( $site_editor_post->ID ) ) { + $site_editor_active_post_lock = wp_set_post_lock( $site_editor_post ); +} + +/* + * Render the shared post lock dialog markup in the Site Editor footer. + * + * The classic editor hooks _admin_notice_post_locked() from + * wp-admin/edit-form-advanced.php; templates never go through that path, so + * the same hook is registered here with the resolved template post swapped + * into $GLOBALS['post'] for the duration of the callback. + */ +if ( $site_editor_post && $site_editor_post_lock_enabled ) { + add_action( + 'admin_footer', + static function () use ( $site_editor_post ) { + $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null; + $GLOBALS['post'] = $site_editor_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restored below. + _admin_notice_post_locked(); + $GLOBALS['post'] = $previous_post; + } + ); +} + // Used in the HTML title tag. $title = _x( 'Editor', 'site editor title tag' ); $parent_file = 'themes.php'; @@ -303,6 +383,37 @@ static function ( $classes ) { wp_enqueue_style( 'wp-format-library' ); wp_enqueue_media(); +// Site Editor post lock bridge. +if ( $site_editor_post && $site_editor_post_lock_enabled ) { + wp_enqueue_script( 'heartbeat' ); + wp_enqueue_script( 'site-editor-post-lock' ); + + wp_localize_script( + 'site-editor-post-lock', + 'wpSiteEditorPostLockL10n', + array( + 'enabled' => true, + 'postId' => $site_editor_post->ID, + 'postType' => $site_editor_post->post_type, + 'lock' => is_array( $site_editor_active_post_lock ) ? implode( ':', $site_editor_active_post_lock ) : '', + 'takeOverUrl' => add_query_arg( + 'get-post-lock', + '1', + wp_nonce_url( + add_query_arg( + array( + 'p' => '/' . $site_editor_post->post_type . '/' . $site_editor_post->ID, + 'canvas' => 'edit', + ), + admin_url( 'site-editor.php' ) + ), + 'lock-post_' . $site_editor_post->ID + ) + ), + ) + ); +} + if ( current_theme_supports( 'wp-block-styles' ) && ( ! is_array( $editor_styles ) || count( $editor_styles ) === 0 ) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 42d42b3f8781d..126e56ea5501d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1449,6 +1449,9 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'post', "/wp-admin/js/post$suffix.js", array( 'suggest', 'wp-lists', 'postbox', 'tags-box', 'underscore', 'word-count', 'wp-a11y', 'wp-sanitize', 'clipboard' ), false, 1 ); $scripts->set_translations( 'post' ); + $scripts->add( 'site-editor-post-lock', "/wp-admin/js/site-editor-post-lock$suffix.js", array( 'jquery', 'heartbeat', 'wp-data', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'site-editor-post-lock' ); + $scripts->add( 'editor-expand', "/wp-admin/js/editor-expand$suffix.js", array( 'jquery', 'underscore' ), false, 1 ); $scripts->add( 'link', "/wp-admin/js/link$suffix.js", array( 'wp-lists', 'postbox' ), false, 1 ); diff --git a/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php b/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php new file mode 100644 index 0000000000000..a021c904bafdb --- /dev/null +++ b/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php @@ -0,0 +1,239 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$other_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + + self::$template_post = $factory->post->create_and_get( + array( + 'post_type' => 'wp_template', + 'post_name' => 'tests-65126-template', + 'post_title' => 'Tests 65126 Template', + 'post_status' => 'publish', + ) + ); + + self::$template_part_post = $factory->post->create_and_get( + array( + 'post_type' => 'wp_template_part', + 'post_name' => 'tests-65126-template-part', + 'post_title' => 'Tests 65126 Template Part', + 'post_status' => 'publish', + ) + ); + + self::$regular_post = $factory->post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Tests 65126 Regular Post', + ) + ); + } + + public function set_up() { + parent::set_up(); + + // Ensure each test starts from a clean lock state. + delete_post_meta( self::$template_post->ID, '_edit_lock' ); + delete_post_meta( self::$template_part_post->ID, '_edit_lock' ); + delete_post_meta( self::$regular_post->ID, '_edit_lock' ); + + require_once ABSPATH . 'wp-admin/includes/post.php'; + + wp_set_current_user( self::$admin_id ); + } + + /** + * The filter callback is registered for both Site Editor post types. + */ + public function test_filters_are_registered() { + $this->assertSame( + 10, + has_filter( 'rest_prepare_wp_template', 'wp_set_site_editor_post_lock_on_rest_prepare' ), + 'rest_prepare_wp_template should have the lock acquisition filter registered.' + ); + $this->assertSame( + 10, + has_filter( 'rest_prepare_wp_template_part', 'wp_set_site_editor_post_lock_on_rest_prepare' ), + 'rest_prepare_wp_template_part should have the lock acquisition filter registered.' + ); + } + + /** + * Edit-context responses for `wp_template` acquire the lock. + */ + public function test_lock_is_acquired_for_wp_template_in_edit_context() { + $response = $this->run_rest_prepare( self::$template_post, 'edit' ); + + $this->assertInstanceOf( WP_REST_Response::class, $response, 'The response should be returned unchanged.' ); + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_post->ID ), + 'The current user should hold the lock after an edit-context REST response.' + ); + } + + /** + * Edit-context responses for `wp_template_part` acquire the lock. + */ + public function test_lock_is_acquired_for_wp_template_part_in_edit_context() { + $this->run_rest_prepare( self::$template_part_post, 'edit' ); + + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_part_post->ID ), + 'The current user should hold the lock after an edit-context REST response.' + ); + } + + /** + * View-context responses do not acquire the lock. + */ + public function test_lock_is_not_acquired_in_view_context() { + $this->run_rest_prepare( self::$template_post, 'view' ); + + $this->assertEmpty( + get_post_meta( self::$template_post->ID, '_edit_lock', true ), + 'View-context REST responses should not acquire a post lock.' + ); + } + + /** + * The `wp_apply_site_editor_post_lock` filter can opt out of acquisition. + */ + public function test_filter_can_opt_out_of_lock_acquisition() { + add_filter( 'wp_apply_site_editor_post_lock', '__return_false' ); + + try { + $this->run_rest_prepare( self::$template_post, 'edit' ); + } finally { + remove_filter( 'wp_apply_site_editor_post_lock', '__return_false' ); + } + + $this->assertEmpty( + get_post_meta( self::$template_post->ID, '_edit_lock', true ), + 'wp_apply_site_editor_post_lock returning false should skip acquisition.' + ); + } + + /** + * The filter receives the resolved post object as its second argument. + */ + public function test_filter_receives_post_object() { + $captured = null; + + $capture = static function ( $apply, $post ) use ( &$captured ) { + $captured = $post; + return $apply; + }; + + add_filter( 'wp_apply_site_editor_post_lock', $capture, 10, 2 ); + + try { + $this->run_rest_prepare( self::$template_post, 'edit' ); + } finally { + remove_filter( 'wp_apply_site_editor_post_lock', $capture, 10 ); + } + + $this->assertInstanceOf( WP_Post::class, $captured, 'The filter should receive a WP_Post instance.' ); + $this->assertSame( self::$template_post->ID, $captured->ID, 'The filter should receive the template post being prepared.' ); + } + + /** + * A lock already held by another user is not overwritten silently. The + * existing wp_set_post_lock() helper simply refreshes the timestamp using + * the current user, so when an admin opens an edit-context request after + * another admin had the lock, the lock now belongs to the new user. This + * verifies that acquisition does happen (the function does not bail out + * because a lock already exists). + */ + public function test_lock_is_refreshed_for_current_user() { + // Pretend another admin opened the template first. + wp_set_current_user( self::$other_admin_id ); + wp_set_post_lock( self::$template_post->ID ); + + wp_set_current_user( self::$admin_id ); + + $this->run_rest_prepare( self::$template_post, 'edit' ); + + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_post->ID ), + 'An edit-context REST response should refresh the lock for the current user.' + ); + } + + /** + * Helper: invoke the REST prepare filter directly with a minimal response + * and a fake request whose context can be controlled per test. + * + * @param WP_Post $post The post being prepared. + * @param string $context Either `edit` or `view`. + * @return WP_REST_Response + */ + protected function run_rest_prepare( WP_Post $post, $context ) { + $response = new WP_REST_Response( array( 'id' => $post->ID ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/' . ( 'wp_template' === $post->post_type ? 'templates' : 'template-parts' ) . '/' . $post->ID ); + $request->set_param( 'context', $context ); + + return wp_set_site_editor_post_lock_on_rest_prepare( $response, $post, $request ); + } + + /** + * Helper: read the user ID portion of the `_edit_lock` meta value. + * + * @param int $post_id Post ID to read. + * @return int Lock user ID, or 0 when no lock is present. + */ + protected function get_lock_user_id( $post_id ) { + $lock = get_post_meta( $post_id, '_edit_lock', true ); + + if ( ! $lock ) { + return 0; + } + + $parts = explode( ':', $lock ); + + return isset( $parts[1] ) ? (int) $parts[1] : 0; + } +}