diff --git a/js/src/duplicate-post-edit-script.js b/js/src/duplicate-post-edit-script.js
index 3587ef8d0..8e1c00518 100644
--- a/js/src/duplicate-post-edit-script.js
+++ b/js/src/duplicate-post-edit-script.js
@@ -1,12 +1,12 @@
/* global duplicatePost, duplicatePostNotices */
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { registerPlugin } from "@wordpress/plugins";
import { PluginDocumentSettingPanel, PluginPostStatusInfo } from "@wordpress/editor";
import { Fragment } from "@wordpress/element";
import { Button, ExternalLink, Modal } from '@wordpress/components';
import { __ } from "@wordpress/i18n";
-import { select, subscribe, dispatch } from "@wordpress/data";
+import { select, subscribe, dispatch, useSelect } from "@wordpress/data";
import apiFetch from "@wordpress/api-fetch";
import { redirectOnSaveCompletion } from "./duplicate-post-functions";
@@ -123,13 +123,62 @@ function DuplicatePostPanel() {
*
* @returns {JSX.Element|null} The rendered component or null.
*/
+/**
+ * Interval in milliseconds for polling the R&R copy meta.
+ *
+ * The R&R copy is created via a server-side admin action, so the meta change
+ * does not enter the RTC CRDT document. Periodic cache invalidation ensures
+ * the editor refetches the entity record and picks up the change.
+ *
+ * @type {number}
+ */
+const RR_COPY_POLL_INTERVAL = 15000;
+
function DuplicatePostRender() {
// Don't try to render anything if there is no store.
if ( ! select( 'core/editor' ) || ! ( wp.editor && wp.editor.PluginPostStatusInfo ) ) {
return null;
}
- const currentPostStatus = select( 'core/editor' ).getEditedPostAttribute( 'status' );
+ const { currentPostStatus, hasRewriteAndRepublishCopy, postType, postId } = useSelect( ( sel ) => {
+ const editor = sel( 'core/editor' );
+ const type = editor.getCurrentPostType();
+ const id = editor.getCurrentPostId();
+ const record = sel( 'core' ).getEntityRecord( 'postType', type, id );
+
+ return {
+ currentPostStatus: editor.getEditedPostAttribute( 'status' ),
+ hasRewriteAndRepublishCopy: !! record?.meta?._dp_has_rewrite_republish_copy,
+ postType: type,
+ postId: id,
+ };
+ }, [] );
+
+ const prevHasRRCopy = useRef( hasRewriteAndRepublishCopy );
+
+ // Periodically invalidate the entity record cache to detect server-side
+ // meta changes (e.g. when another user creates an R&R copy via admin action).
+ useEffect( () => {
+ if ( hasRewriteAndRepublishCopy || ! postType || ! postId ) {
+ return;
+ }
+ const interval = setInterval( () => {
+ dispatch( 'core' ).invalidateResolution( 'getEntityRecord', [ 'postType', postType, postId ] );
+ }, RR_COPY_POLL_INTERVAL );
+ return () => clearInterval( interval );
+ }, [ hasRewriteAndRepublishCopy, postType, postId ] );
+
+ // Notify the user when another collaborator creates an R&R copy.
+ useEffect( () => {
+ if ( hasRewriteAndRepublishCopy && ! prevHasRRCopy.current ) {
+ dispatch( 'core/notices' ).createNotice(
+ 'info',
+ __( 'Another user has started a Rewrite & Republish for this post.', 'duplicate-post' ),
+ { isDismissible: true, type: 'snackbar' },
+ );
+ }
+ prevHasRRCopy.current = hasRewriteAndRepublishCopy;
+ }, [ hasRewriteAndRepublishCopy ] );
return (
@@ -146,7 +195,7 @@ function DuplicatePostRender() {
}
- { ( currentPostStatus === 'publish' && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
+ { ( currentPostStatus === 'publish' && ! hasRewriteAndRepublishCopy && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
';
}
+
+ if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
+ echo ''
+ . \esc_html( $this->get_collab_redirect_notice_text() )
+ . '
';
+ }
}
/**
@@ -104,5 +123,19 @@ public function add_block_editor_notice() {
'before',
);
}
+
+ if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
+ $notice = [
+ 'text' => $this->get_collab_redirect_notice_text(),
+ 'status' => 'info',
+ 'isDismissible' => true,
+ ];
+
+ \wp_add_inline_script(
+ 'duplicate_post_edit_script',
+ 'duplicatePostNotices.collab_redirect_notice = ' . \wp_json_encode( $notice ) . ';',
+ 'before',
+ );
+ }
}
}
diff --git a/tests/Unit/UI/Block_Editor_Test.php b/tests/Unit/UI/Block_Editor_Test.php
index 18730431c..21e266bfd 100644
--- a/tests/Unit/UI/Block_Editor_Test.php
+++ b/tests/Unit/UI/Block_Editor_Test.php
@@ -95,6 +95,17 @@ public function test_constructor() {
public function test_register_hooks() {
$this->instance->register_hooks();
+ $this->assertNotFalse(
+ \has_action(
+ 'init',
+ [
+ $this->instance,
+ 'register_post_meta',
+ ],
+ ),
+ 'Does not have expected init action',
+ );
+
$this->assertNotFalse(
\has_action(
'elementor/editor/after_enqueue_styles',
@@ -151,6 +162,39 @@ public function test_register_hooks() {
);
}
+ /**
+ * Tests that register_post_meta registers the meta for each enabled post type.
+ *
+ * @covers \Yoast\WP\Duplicate_Post\UI\Block_Editor::register_post_meta
+ *
+ * @return void
+ */
+ public function test_register_post_meta() {
+ $this->permissions_helper
+ ->expects( 'get_enabled_post_types' )
+ ->andReturn( [ 'post', 'page' ] );
+
+ $expected_args = Mockery::on(
+ static function ( $args ) {
+ return $args['show_in_rest'] === true
+ && $args['single'] === true
+ && $args['type'] === 'string'
+ && $args['sanitize_callback'] === 'sanitize_text_field'
+ && \is_callable( $args['auth_callback'] );
+ },
+ );
+
+ Monkey\Functions\expect( '\register_post_meta' )
+ ->once()
+ ->with( 'post', '_dp_has_rewrite_republish_copy', $expected_args );
+
+ Monkey\Functions\expect( '\register_post_meta' )
+ ->once()
+ ->with( 'page', '_dp_has_rewrite_republish_copy', $expected_args );
+
+ $this->instance->register_post_meta();
+ }
+
/**
* Tests the should_previously_used_keyword_assessment_run function.
*
@@ -268,6 +312,7 @@ public function test_enqueue_block_editor_scripts() {
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
$post = Mockery::mock( WP_Post::class );
$post->ID = 123;
+ $post->post_type = 'post';
$new_draft_link = 'http://fakeu.rl/new_draft';
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
$rewriting = 0;
@@ -332,8 +377,16 @@ public function test_enqueue_block_editor_scripts() {
->with( $post )
->andReturnNull();
+ $post_type_object = Mockery::mock( 'WP_Post_Type' );
+ $post_type_object->rest_base = 'posts';
+
+ Monkey\Functions\expect( '\get_post_type_object' )
+ ->with( 'post' )
+ ->andReturn( $post_type_object );
+
$edit_js_object = [
'postId' => 123,
+ 'restBase' => 'posts',
'newDraftLink' => $new_draft_link,
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
'showLinks' => $show_links,
@@ -372,6 +425,7 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
$post = Mockery::mock( WP_Post::class );
$post->ID = 123;
+ $post->post_type = 'post';
$new_draft_link = 'http://fakeu.rl/new_draft';
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
$rewriting = 1;
@@ -433,8 +487,16 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
->with( $post )
->andReturnNull();
+ $post_type_object = Mockery::mock( 'WP_Post_Type' );
+ $post_type_object->rest_base = 'posts';
+
+ Monkey\Functions\expect( '\get_post_type_object' )
+ ->with( 'post' )
+ ->andReturn( $post_type_object );
+
$edit_js_object = [
'postId' => 123,
+ 'restBase' => 'posts',
'newDraftLink' => $new_draft_link,
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
'showLinks' => $show_links,
diff --git a/tests/Unit/Watchers/Republished_Post_Watcher_Test.php b/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
index 08d3517e5..d35bbf755 100644
--- a/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
+++ b/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
@@ -241,6 +241,7 @@ public function test_add_removable_query_args() {
'dprepublished',
'dpcopy',
'dpnonce',
+ 'dpcollabredirected',
],
$this->instance->add_removable_query_args( $array ),
);
diff --git a/tests/WP/Post_Duplicator_Test.php b/tests/WP/Post_Duplicator_Test.php
index bb7414d8e..de900fa07 100644
--- a/tests/WP/Post_Duplicator_Test.php
+++ b/tests/WP/Post_Duplicator_Test.php
@@ -2,6 +2,7 @@
namespace Yoast\WP\Duplicate_Post\Tests\WP;
+use WP_Error;
use Yoast\WP\Duplicate_Post\Post_Duplicator;
use Yoast\WPTestUtils\WPIntegration\TestCase;
@@ -50,4 +51,72 @@ public function test_create_duplicate() {
$this->assertIsInt( $id );
}
+
+ /**
+ * Tests that creating an R&R copy claims the slot on the original post.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_claims_slot() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ $copy_id = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertIsInt( $copy_id );
+ $this->assertSame( (string) $copy_id, \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', true ) );
+ $this->assertSame( 1, (int) \get_post_meta( $copy_id, '_dp_is_rewrite_republish_copy', true ) );
+ }
+
+ /**
+ * Tests that creating an R&R copy fails when the original already has one.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_blocks_duplicate() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ $first_copy_id = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertIsInt( $first_copy_id );
+
+ $second_result = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertInstanceOf( WP_Error::class, $second_result );
+ $this->assertSame( 'duplicate_post_already_has_copy', $second_result->get_error_code() );
+
+ // Original should still reference the first copy only.
+ $meta_values = \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', false );
+ $this->assertCount( 1, $meta_values );
+ $this->assertSame( (string) $first_copy_id, $meta_values[0] );
+ }
+
+ /**
+ * Tests that the claim is rolled back when copy creation fails.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_rolls_back_on_failure() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ // Force wp_insert_post to fail by filtering the post data.
+ \add_filter(
+ 'wp_insert_post_empty_content',
+ '__return_true',
+ );
+
+ $result = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ \remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+
+ // The claim should have been rolled back.
+ $this->assertSame( '', \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', true ) );
+ }
}