From 9b3a557121ad571b78dfa3e85174295773ccbc4a Mon Sep 17 00:00:00 2001 From: Enrico Battocchi Date: Fri, 27 Mar 2026 17:59:09 +0100 Subject: [PATCH 1/6] feat(editor): dynamically hide R&R button when a copy already exists Register _dp_has_rewrite_republish_copy post meta for the REST API and read it reactively via the core store's getEntityRecord. Since the R&R copy is created via a server-side admin action (not through the editor), the meta change does not enter the RTC CRDT document. Periodic cache invalidation (every 15s) ensures the editor refetches the entity record and picks up the change. A snackbar notification informs the user when another collaborator has created an R&R copy, explaining why the button disappeared. Ref: Yoast/reserved-tasks#1127 Co-Authored-By: Claude Opus 4.6 (1M context) --- js/src/duplicate-post-edit-script.js | 57 ++++++++++++++++++++++++++-- src/ui/block-editor.php | 30 +++++++++++++++ tests/Unit/UI/Block_Editor_Test.php | 42 ++++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/js/src/duplicate-post-edit-script.js b/js/src/duplicate-post-edit-script.js index 3587ef8d0..70053b61a 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' ) &&