diff --git a/lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php b/lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php index 0eca387e1b68ce..cdf3245d79d239 100644 --- a/lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php +++ b/lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php @@ -98,9 +98,9 @@ public function create_item( $request ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } - // Post-type collaboration support is determined after the autosaves - // controller is selected, so disabled post types must delegate to Core. - if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + // Collaboration support is determined after the autosaves controller + // is selected, so posts with collaboration disabled must delegate to Core. + if ( wp_is_post_collaboration_disabled( $post ) ) { return parent::create_item( $request ); } diff --git a/lib/experimental/collaboration/collaboration.php b/lib/experimental/collaboration/collaboration.php index e5f27c9d010415..bc8003a7267c3d 100644 --- a/lib/experimental/collaboration/collaboration.php +++ b/lib/experimental/collaboration/collaboration.php @@ -181,6 +181,39 @@ function wp_is_post_type_collaboration_disabled( $post_type ) { } } +if ( ! function_exists( 'wp_is_post_collaboration_disabled' ) ) { + /** + * Determines whether real-time collaboration is disabled for a post. + * + * Checked when the editor loads, so a status change applies on the next load. + * + * @since 7.2.0 + * + * @param int|WP_Post $post Post ID or post object. + * @return bool Whether real-time collaboration is disabled for the post. + */ + function wp_is_post_collaboration_disabled( $post ) { + $post = get_post( $post ); + if ( ! $post ) { + return true; + } + + if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + return true; + } + + /** + * Filters whether real-time collaboration is disabled for a post. + * + * @since 7.2.0 + * + * @param bool $disabled Whether real-time collaboration is disabled for the post. + * @param WP_Post $post Post object. + */ + return (bool) apply_filters( 'wp_is_post_collaboration_disabled', false, $post ); + } +} + /** * Disables real-time collaboration for post types that cannot persist the * CRDT document. @@ -340,7 +373,7 @@ function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response, $data = arr } $post = get_post( $post_id ); - if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + if ( ! $post || wp_is_post_collaboration_disabled( $post ) ) { continue; } @@ -376,7 +409,7 @@ function gutenberg_block_quick_edit_for_active_lock() { } $post = get_post( $post_id ); - if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + if ( ! $post || wp_is_post_collaboration_disabled( $post ) ) { return; } @@ -504,7 +537,7 @@ function gutenberg_post_list_collaboration_row_actions( $actions, $post ) { return $actions; } - if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + if ( wp_is_post_collaboration_disabled( $post ) ) { return $actions; } @@ -566,7 +599,7 @@ function gutenberg_add_autosave_details_to_editor_settings( $settings, $block_ed $post = $block_editor_context->post; - if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { + if ( wp_is_post_collaboration_disabled( $post ) ) { return $settings; } diff --git a/lib/experimental/collaboration/rest-api.php b/lib/experimental/collaboration/rest-api.php index a48132084e9ae4..9c11dcdd9901bd 100644 --- a/lib/experimental/collaboration/rest-api.php +++ b/lib/experimental/collaboration/rest-api.php @@ -29,3 +29,45 @@ function gutenberg_override_autosaves_rest_controller( $args ) { } add_filter( 'register_post_type_args', 'gutenberg_override_autosaves_rest_controller', 10, 1 ); + +/** + * Adds a `collaboration_disabled` field to post types that support real-time + * collaboration, so the editor knows whether to sync the post it loads. + * + * @since 7.2.0 + */ +function gutenberg_register_collaboration_disabled_rest_field() { + if ( ! wp_is_collaboration_enabled() ) { + return; + } + + $post_types = array_values( + array_filter( + get_post_types( array( 'show_in_rest' => true ) ), + static function ( $post_type ) { + return ! wp_is_post_type_collaboration_disabled( $post_type ); + } + ) + ); + + register_rest_field( + $post_types, + 'collaboration_disabled', + array( + 'schema' => array( + 'description' => __( 'Whether real-time collaboration is disabled for the post.', 'gutenberg' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'get_callback' => static function ( $item ) { + // Templates use a string ID and carry the post ID in `wp_id`. + $post = get_post( isset( $item['wp_id'] ) ? $item['wp_id'] : $item['id'] ); + + // Leave records that are not stored as posts to the post type check. + return $post ? wp_is_post_collaboration_disabled( $post ) : false; + }, + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_collaboration_disabled_rest_field' ); diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index 36bb572a67d80a..084f684eebec15 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -6,6 +6,7 @@ - `Icon`: add the `keywords` field exposed by the icons REST endpoint ([#82367](https://github.com/WordPress/gutenberg/pull/82367)). - `__experimentalFetchLinkSuggestions`: add a `preferTypes` option to rank matches from one or more search types above the usual order ([#83408](https://github.com/WordPress/gutenberg/pull/83408)). +- Real-time collaboration skips syncing post records whose `collaboration_disabled` field is `true`, which the server sets through the `wp_is_post_collaboration_disabled` filter ([#84026](https://github.com/WordPress/gutenberg/pull/84026)). ### Bug Fixes diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 6c71cec6cb3d28..b0635768a367ab 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -441,11 +441,11 @@ async function loadPostTypeEntities() { null ); }, - shouldSync: () => + shouldSync: ( objectType, objectId, record ) => ! ( Array.isArray( window._wpCollaborationDisabledPostTypes ) && window._wpCollaborationDisabledPostTypes.includes( name ) - ), + ) && ! record?.collaboration_disabled, }; return entity; diff --git a/packages/core-data/src/test/entities.jsdom.test.js b/packages/core-data/src/test/entities.jsdom.test.js index 74818b780b3165..b66a7e479c9280 100644 --- a/packages/core-data/src/test/entities.jsdom.test.js +++ b/packages/core-data/src/test/entities.jsdom.test.js @@ -251,6 +251,39 @@ describe( 'loadPostTypeEntities', () => { expect( bookEntity.syncConfig.shouldSync() ).toBe( false ); } ); + it( 'should not sync post records disabled for collaboration', async () => { + window.__experimentalEnableRealTimeCollaboration = true; + window._wpCollaborationDisabledPostTypes = []; + + const mockPostTypes = { + book: { + name: 'Books', + rest_base: 'books', + rest_namespace: 'wp/v2', + }, + }; + + apiFetch.mockResolvedValueOnce( mockPostTypes ); + + const postTypeLoader = additionalEntityConfigLoaders.find( + ( loader ) => loader.kind === 'postType' + ); + const entities = await postTypeLoader.loadEntities(); + const { shouldSync } = entities.find( + ( e ) => e.name === 'book' + ).syncConfig; + + expect( + shouldSync( 'postType/book', '1', { collaboration_disabled: true } ) + ).toBe( false ); + expect( + shouldSync( 'postType/book', '1', { + collaboration_disabled: false, + } ) + ).toBe( true ); + expect( shouldSync( 'postType/book', '1' ) ).toBe( true ); + } ); + it( 'should skip taxonomy rest_base when taxonomy is not found in fetched taxonomies', async () => { window.__experimentalEnableRealTimeCollaboration = true; diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js index 964f8d9d96ea0c..57f305dcccb532 100644 --- a/packages/editor/src/store/private-selectors.js +++ b/packages/editor/src/store/private-selectors.js @@ -600,7 +600,12 @@ export const isCollaborationEnabledForCurrentPost = createRegistrySelector( false !== syncConfig.shouldSync?.( `postType/${ currentPostType }`, - currentPostId + currentPostId, + select( coreStore ).getRawEntityRecord( + 'postType', + currentPostType, + currentPostId + ) ) ); } diff --git a/packages/editor/src/store/test/private-selectors.jsdom.test.js b/packages/editor/src/store/test/private-selectors.jsdom.test.js index d2c865ea29e8bb..a87734b85a738a 100644 --- a/packages/editor/src/store/test/private-selectors.jsdom.test.js +++ b/packages/editor/src/store/test/private-selectors.jsdom.test.js @@ -99,12 +99,14 @@ describe( 'isCollaborationEnabledForCurrentPost', () => { function setupRegistry( { collaborationSupported = true, syncConfig = {}, + record = { id: 123 }, } = {} ) { isCollaborationEnabledForCurrentPost.registry = { select: ( store ) => { if ( store === coreStore ) { const selectors = { getEntityConfig: () => ( { syncConfig } ), + getRawEntityRecord: () => record, }; lock( selectors, { isCollaborationSupported: () => collaborationSupported, @@ -124,7 +126,9 @@ describe( 'isCollaborationEnabledForCurrentPost', () => { const state = { postType: 'book', postId: 123 }; expect( isCollaborationEnabledForCurrentPost( state ) ).toBe( true ); - expect( shouldSync ).toHaveBeenCalledWith( 'postType/book', 123 ); + expect( shouldSync ).toHaveBeenCalledWith( 'postType/book', 123, { + id: 123, + } ); } ); it( 'returns false when the current post type sync config does not support persistence', () => { @@ -149,6 +153,21 @@ describe( 'isCollaborationEnabledForCurrentPost', () => { expect( isCollaborationEnabledForCurrentPost( state ) ).toBe( false ); } ); + + it( 'returns false when the sync config should not sync the current post', () => { + setupRegistry( { + syncConfig: { + shouldSync: ( objectType, objectId, record ) => + ! record.collaboration_disabled, + supportsPersistence: true, + }, + record: { id: 123, collaboration_disabled: true }, + } ); + + const state = { postType: 'book', postId: 123 }; + + expect( isCollaborationEnabledForCurrentPost( state ) ).toBe( false ); + } ); } ); describe( 'getDefaultRenderingMode', () => { diff --git a/packages/sync/CHANGELOG.md b/packages/sync/CHANGELOG.md index 7c1e7862871055..bf196afd215b09 100644 --- a/packages/sync/CHANGELOG.md +++ b/packages/sync/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- `SyncConfig.shouldSync` receives the entity record as an optional third argument, so a config can decide per record whether to sync it ([#84026](https://github.com/WordPress/gutenberg/pull/84026)). + ### Bug Fixes - The sync manager's undo manager no longer stands in for the editor's undo manager. It is the undo history of the loaded entities only: it reports each level Yjs opens through the new `onUndoLevelOpened` record handler (replacing `onUndoStackChange`), and `undo()` and `redo()` take the entity whose level to move and return whether a level was moved. Each entity has its own Yjs undo manager, which replaces the copied `YMultiDocUndoManager`. `stopCapturing()` and `clearRedo()` let the consumer keep its own history in step. The manager exposes `isLoaded()`, and its undo manager exists from creation instead of from the first load. Together with the `core-data` change, undo and redo keep working for entities collaboration does not sync ([#83888](https://github.com/WordPress/gutenberg/pull/83888)). diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 417407e27b7c85..e6c05c3806114f 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -193,7 +193,9 @@ export function createSyncManager( debug = false ): SyncManager { return; // Already bootstrapped. } - if ( false === syncConfig.shouldSync?.( objectType, objectId ) ) { + if ( + false === syncConfig.shouldSync?.( objectType, objectId, record ) + ) { log( 'loadEntity', 'shouldSync false, skipping', entityId ); return; // Sync config indicates that this entity should not be synced. } diff --git a/packages/sync/src/test/manager.jsdom.test.ts b/packages/sync/src/test/manager.jsdom.test.ts index bb1f223c2be7ea..1bf61c1874e027 100644 --- a/packages/sync/src/test/manager.jsdom.test.ts +++ b/packages/sync/src/test/manager.jsdom.test.ts @@ -1234,7 +1234,8 @@ describe( 'SyncManager', () => { expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'post', - '123' + '123', + mockRecord ); expect( mockSyncConfig.applyChangesToCRDTDoc @@ -1257,7 +1258,8 @@ describe( 'SyncManager', () => { expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'post', - '123' + '123', + mockRecord ); expect( mockSyncConfig.applyChangesToCRDTDoc diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 3ca7180b4b9815..2919508b3f6706 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -152,7 +152,8 @@ export interface SyncConfig { getPersistedCRDTDoc?: ( record: ObjectData ) => string | null; shouldSync?: ( objectType: ObjectType, - objectId: ObjectID | null + objectId: ObjectID | null, + record?: ObjectData ) => boolean; supportsPersistence?: boolean; } diff --git a/phpunit/tests/collaboration/postCollaborationDisabled.php b/phpunit/tests/collaboration/postCollaborationDisabled.php new file mode 100644 index 00000000000000..5eacfd89fbe7c9 --- /dev/null +++ b/phpunit/tests/collaboration/postCollaborationDisabled.php @@ -0,0 +1,132 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$draft_id = $factory->post->create( array( 'post_status' => 'draft' ) ); + self::$published_id = $factory->post->create( array( 'post_status' => 'publish' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + wp_delete_post( self::$draft_id, true ); + wp_delete_post( self::$published_id, true ); + } + + public function set_up() { + global $wp_rest_server; + + parent::set_up(); + + // Fire `rest_api_init` again so the REST field is registered for this test. + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + + public function tear_down() { + global $wp_rest_server; + + $wp_rest_server = null; + $this->filter_args = array(); + + parent::tear_down(); + } + + public function test_collaboration_is_enabled_for_posts_by_default() { + $this->assertFalse( wp_is_post_collaboration_disabled( self::$draft_id ) ); + $this->assertFalse( wp_is_post_collaboration_disabled( get_post( self::$published_id ) ) ); + } + + public function test_collaboration_is_disabled_for_missing_posts() { + $this->assertTrue( wp_is_post_collaboration_disabled( PHP_INT_MAX ) ); + } + + public function test_filter_receives_post_object() { + add_filter( 'wp_is_post_collaboration_disabled', array( $this, 'record_filter_args' ), 10, 2 ); + + wp_is_post_collaboration_disabled( self::$draft_id ); + + $this->assertFalse( $this->filter_args[0] ); + $this->assertInstanceOf( WP_Post::class, $this->filter_args[1] ); + $this->assertSame( self::$draft_id, $this->filter_args[1]->ID ); + } + + public function test_filter_can_disable_collaboration_by_post_status() { + add_filter( 'wp_is_post_collaboration_disabled', array( $this, 'disable_published_posts' ), 10, 2 ); + + $this->assertFalse( wp_is_post_collaboration_disabled( self::$draft_id ) ); + $this->assertTrue( wp_is_post_collaboration_disabled( self::$published_id ) ); + } + + public function test_disabled_post_type_skips_post_filter() { + add_filter( 'wp_is_post_type_collaboration_disabled', '__return_true' ); + add_filter( 'wp_is_post_collaboration_disabled', array( $this, 'record_filter_args' ), 10, 2 ); + + $this->assertTrue( wp_is_post_collaboration_disabled( self::$draft_id ) ); + $this->assertSame( array(), $this->filter_args ); + } + + public function test_rest_field_reflects_post_filter_in_edit_context() { + wp_set_current_user( self::$admin_id ); + add_filter( 'wp_is_post_collaboration_disabled', array( $this, 'disable_published_posts' ), 10, 2 ); + + $this->assertFalse( $this->get_rest_field( self::$draft_id, 'edit' ) ); + $this->assertTrue( $this->get_rest_field( self::$published_id, 'edit' ) ); + } + + public function test_rest_field_is_omitted_in_view_context() { + $response = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$published_id ) ); + + $this->assertArrayNotHasKey( 'collaboration_disabled', $response->get_data() ); + } + + public function test_post_list_row_actions_are_unchanged_for_disabled_post() { + add_filter( 'wp_is_post_collaboration_disabled', array( $this, 'disable_published_posts' ), 10, 2 ); + + $actions = array( + 'edit' => 'Edit', + ); + + $this->assertSame( + $actions, + gutenberg_post_list_collaboration_row_actions( $actions, get_post( self::$published_id ) ) + ); + } + + public function record_filter_args( $disabled, $post ) { + $this->filter_args = array( $disabled, $post ); + return $disabled; + } + + public function disable_published_posts( $disabled, $post ) { + return 'publish' === $post->post_status ? true : $disabled; + } + + private function get_rest_field( $post_id, $context ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_id ); + $request->set_param( 'context', $context ); + + $data = rest_do_request( $request )->get_data(); + + return $data['collaboration_disabled'] ?? null; + } +}