From 32e09324c779981a199bda9883e2a03dae738c1b Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sun, 5 Jul 2026 17:33:39 +0200 Subject: [PATCH 1/8] Comments: Allow changing a comment's parent from the Edit Comment screen. Adds an editable 'In reply to' control to the Edit Comment screen, following the pattern of the 'Submitted on' toggle: an Edit link reveals a select of the other comments on the same post (author and trimmed excerpt), excluding the comment itself and its descendants. edit_comment() now reads comment_parent from the submitted form and validates it before updating: the parent must be another comment on the same post, and cannot be the comment itself or one of its descendants, preventing threading loops. Trac ticket: https://core.trac.wordpress.org/ticket/65570 Co-Authored-By: Claude Fable 5 --- src/js/_enqueues/admin/comment.js | 70 ++++++ src/wp-admin/css/edit.css | 10 + src/wp-admin/edit-form-comment.php | 137 ++++++++++-- src/wp-admin/includes/comment.php | 38 ++++ .../includes/comment/EditComment_Test.php | 199 ++++++++++++++++++ 5 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 tests/phpunit/tests/admin/includes/comment/EditComment_Test.php diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js index 188caab2c30c5..738bc4dd1e63a 100644 --- a/src/js/_enqueues/admin/comment.js +++ b/src/js/_enqueues/admin/comment.js @@ -99,4 +99,74 @@ jQuery( function($) { $edittimestamp.show().trigger( 'focus' ); $timestampdiv.slideUp( 'fast' ); }); + + var $commentparentdiv = $( '#comment-parent-div' ), + $commentparentdisplay = $( '#comment-parent-display' ), + commentparent = $commentparentdisplay.html(), + $editcommentparent = $commentparentdiv.siblings( 'a.edit-comment-parent' ); + + /** + * Adds event that opens the parent comment form if the form is hidden. + * + * @since 7.1.0 + * + * @listens $editcommentparent:click + * + * @param {Event} event The event object. + * @return {void} + */ + $editcommentparent.on( 'click', function( event ) { + if ( $commentparentdiv.is( ':hidden' ) ) { + // Slide down the form and set focus on the parent field. + $commentparentdiv.slideDown( 'fast', function() { + $( '#comment_parent' ).trigger( 'focus' ); + } ); + $(this).hide(); + } + event.preventDefault(); + }); + + /** + * Resets the parent comment value when the cancel button is clicked. + * + * @since 7.1.0 + * + * @listens .cancel-comment-parent:click + * + * @param {Event} event The event object. + * @return {void} + */ + $commentparentdiv.find( '.cancel-comment-parent' ).on( 'click', function( event ) { + // Move focus back to the Edit link. + $editcommentparent.show().trigger( 'focus' ); + $commentparentdiv.slideUp( 'fast' ); + $( '#comment_parent' ).val( $( '#hidden_comment_parent' ).val() ); + $commentparentdisplay.html( commentparent ); + event.preventDefault(); + }); + + /** + * Updates the parent comment display when the ok button is clicked. + * + * @since 7.1.0 + * + * @listens .save-comment-parent:click + * + * @param {Event} event The event object. + * @return {void} + */ + $commentparentdiv.find( '.save-comment-parent' ).on( 'click', function( event ) { + var $selected = $( '#comment_parent option:selected' ), + parentLabel = $selected.data( 'author' ) || $selected.text(); + + event.preventDefault(); + + $commentparentdisplay + .text( wp.i18n.__( 'In reply to:' ) + ' ' ) + .append( $( '' ).text( parentLabel ) ); + + // Move focus back to the Edit link. + $editcommentparent.show().trigger( 'focus' ); + $commentparentdiv.slideUp( 'fast' ); + }); }); diff --git a/src/wp-admin/css/edit.css b/src/wp-admin/css/edit.css index 563151a3cdda6..e96cf4692fc03 100644 --- a/src/wp-admin/css/edit.css +++ b/src/wp-admin/css/edit.css @@ -554,6 +554,16 @@ form#tags-filter { line-height: 1.76923076; } +#comment-parent-div { + padding-top: 5px; + /* Fieldsets cannot shrink below their content by default, allow it so the select cannot cause overflow. */ + min-width: 0; +} + +#comment-parent-div select { + width: 100%; +} + #timestampdiv p { margin: 8px 0 6px; } diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php index e0bbc9f657a73..c6bca346f744e 100644 --- a/src/wp-admin/edit-form-comment.php +++ b/src/wp-admin/edit-form-comment.php @@ -201,25 +201,130 @@ comment_parent ) : +$parent_display = __( 'None' ); + +if ( $comment->comment_parent ) { $parent = get_comment( $comment->comment_parent ); - if ( $parent ) : - $parent_link = esc_url( get_comment_link( $parent ) ); - $name = get_comment_author( $parent ); - ?> -
- ' . $name . ''; + } +} + +$post_comments = get_comments( + array( + 'post_id' => $comment->comment_post_ID, + 'type' => 'comment', + 'status' => 'all', + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + ) +); + +// Group comments by parent to find this comment's descendants, which cannot become its parent. +$comments_by_parent = array(); + +foreach ( $post_comments as $post_comment ) { + $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID; +} + +$comment_descendants = array(); +$comment_queue = array( $comment->comment_ID ); + +while ( $comment_queue ) { + $descendant_parent = array_shift( $comment_queue ); + + if ( isset( $comments_by_parent[ $descendant_parent ] ) ) { + foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) { + $comment_descendants[ $descendant_id ] = true; + $comment_queue[] = $descendant_id; + } + } +} +?> +
+ +' . $parent_display . '' +); +?> + + + + +
+ + + + + + +

+ + +

+
+
comment_parent ) { + if ( $comment_parent === $comment_id ) { + return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to itself.' ) ); + } + + if ( $comment_parent ) { + $parent = get_comment( $comment_parent ); + + if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) { + return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) ); + } + + // Walk up the new parent's ancestors to prevent creating a threading loop. + $ancestors = array(); + $ancestor = $parent; + + while ( $ancestor && $ancestor->comment_parent && ! isset( $ancestors[ $ancestor->comment_ID ] ) ) { + if ( (int) $ancestor->comment_parent === $comment_id ) { + return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to one of its own replies.' ) ); + } + + $ancestors[ $ancestor->comment_ID ] = true; + + $ancestor = get_comment( $ancestor->comment_parent ); + } + } + } + } + foreach ( array( 'aa', 'mm', 'jj', 'hh', 'mn' ) as $timeunit ) { if ( ! empty( $_POST[ 'hidden_' . $timeunit ] ) && $_POST[ 'hidden_' . $timeunit ] !== $_POST[ $timeunit ] ) { $_POST['edit_date'] = '1'; diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php new file mode 100644 index 0000000000000..36436101dcdff --- /dev/null +++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php @@ -0,0 +1,199 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$post_id = $factory->post->create(); + self::$other_post_id = $factory->post->create(); + } + + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down() { + $_POST = array(); + + parent::tear_down(); + } + + /** + * Calls edit_comment() with a comment ID and a new parent, as submitted from the Edit Comment screen. + * + * @param int $comment_id Comment ID. + * @param int $comment_parent New parent comment ID. + * @return int|WP_Error The edit_comment() return value. + */ + private function update_comment_parent( $comment_id, $comment_parent ) { + $_POST = array( + 'comment_ID' => $comment_id, + 'comment_parent' => $comment_parent, + ); + + return edit_comment(); + } + + /** + * @ticket 65570 + */ + public function test_should_update_comment_parent() { + $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $result = $this->update_comment_parent( $comment_id, $parent_id ); + + $this->assertSame( 1, $result ); + $this->assertSame( (string) $parent_id, get_comment( $comment_id )->comment_parent ); + } + + /** + * @ticket 65570 + */ + public function test_should_allow_clearing_comment_parent() { + $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $parent_id, + ) + ); + + $result = $this->update_comment_parent( $comment_id, 0 ); + + $this->assertSame( 1, $result ); + $this->assertSame( '0', get_comment( $comment_id )->comment_parent ); + } + + /** + * @ticket 65570 + */ + public function test_should_reject_parent_on_a_different_post() { + $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$other_post_id ) ); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $result = $this->update_comment_parent( $comment_id, $parent_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_parent_invalid', $result->get_error_code() ); + $this->assertSame( '0', get_comment( $comment_id )->comment_parent ); + } + + /** + * @ticket 65570 + */ + public function test_should_reject_nonexistent_parent() { + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $result = $this->update_comment_parent( $comment_id, $comment_id + 1000 ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_parent_invalid', $result->get_error_code() ); + } + + /** + * @ticket 65570 + */ + public function test_should_reject_comment_as_its_own_parent() { + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $result = $this->update_comment_parent( $comment_id, $comment_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_parent_invalid', $result->get_error_code() ); + } + + /** + * @ticket 65570 + */ + public function test_should_reject_child_as_parent() { + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $child_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $comment_id, + ) + ); + + $result = $this->update_comment_parent( $comment_id, $child_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_parent_invalid', $result->get_error_code() ); + } + + /** + * @ticket 65570 + */ + public function test_should_reject_descendant_as_parent() { + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $child_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $comment_id, + ) + ); + $grandchild_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $child_id, + ) + ); + + $result = $this->update_comment_parent( $comment_id, $grandchild_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_parent_invalid', $result->get_error_code() ); + } + + /** + * @ticket 65570 + */ + public function test_should_allow_resubmitting_unchanged_parent() { + $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $parent_id, + ) + ); + + $result = $this->update_comment_parent( $comment_id, $parent_id ); + + $this->assertNotWPError( $result ); + $this->assertSame( (string) $parent_id, get_comment( $comment_id )->comment_parent ); + } +} From 4a909819ea830ea8788fec016f28dcf4b47eb4a7 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 6 Jul 2026 10:56:06 +0200 Subject: [PATCH 2/8] Comments: Address review feedback on the parent comment picker. - Escape the comment author name in the 'In reply to' link. - Cast comment_ID to int for the strict comparisons, since the raw WP_Comment property is a numeric string. - Use isset() for the unlisted-parent fallback check to aid static analysis. - Use single-quoted attributes in the option printf format strings. - Use native typed properties in the new test class. Co-Authored-By: Claude Fable 5 --- src/wp-admin/edit-form-comment.php | 18 ++++++++++-------- .../includes/comment/EditComment_Test.php | 12 +++--------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php index c6bca346f744e..3dd36994b9778 100644 --- a/src/wp-admin/edit-form-comment.php +++ b/src/wp-admin/edit-form-comment.php @@ -207,9 +207,11 @@ $parent = get_comment( $comment->comment_parent ); if ( $parent ) { - $parent_link = esc_url( get_comment_link( $parent ) ); - $name = get_comment_author( $parent ); - $parent_display = '' . $name . ''; + $parent_display = sprintf( + '%s', + esc_url( get_comment_link( $parent ) ), + esc_html( get_comment_author( $parent ) ) + ); } } @@ -231,7 +233,7 @@ } $comment_descendants = array(); -$comment_queue = array( $comment->comment_ID ); +$comment_queue = array( (int) $comment->comment_ID ); while ( $comment_queue ) { $descendant_parent = array_shift( $comment_queue ); @@ -276,7 +278,7 @@ foreach ( $post_comments as $post_comment ) { $post_comment_id = (int) $post_comment->comment_ID; - if ( $post_comment_id === $comment->comment_ID || isset( $comment_descendants[ $post_comment_id ] ) ) { + if ( $post_comment_id === (int) $comment->comment_ID || isset( $comment_descendants[ $post_comment_id ] ) ) { continue; } @@ -292,7 +294,7 @@ ); printf( - "\t\n", + "\t\n", $post_comment_id, esc_attr( get_comment_author( $post_comment ) ), selected( $post_comment_id, (int) $comment->comment_parent, false ), @@ -301,7 +303,7 @@ } // The current parent may not be listed, e.g. a pingback or a comment no longer publicly visible. - if ( $comment->comment_parent && ! $current_parent_listed && $parent ) { + if ( $comment->comment_parent && ! $current_parent_listed && isset( $parent ) ) { $option_label = sprintf( /* translators: 1: Comment author, 2: Comment excerpt. */ __( '%1$s: %2$s' ), @@ -310,7 +312,7 @@ ); printf( - "\t\n", + "\t\n", (int) $comment->comment_parent, esc_attr( get_comment_author( $parent ) ), esc_html( $option_label ) diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php index 36436101dcdff..75ba89bb3a8db 100644 --- a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php @@ -10,24 +10,18 @@ class Admin_Includes_Comment_EditComment_Test extends WP_UnitTestCase { /** * Admin user ID. - * - * @var int */ - public static $admin_id; + public static int $admin_id; /** * Post ID to add comments to. - * - * @var int */ - public static $post_id; + public static int $post_id; /** * Another post ID, to test cross-post re-parenting. - * - * @var int */ - public static $other_post_id; + public static int $other_post_id; /** * Create the user and posts for the tests. From e93b9248607e7442aa3136781a19cfc161766740 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 7 Jul 2026 11:12:30 +0200 Subject: [PATCH 3/8] Address review feedback on the comment parent editing UI. - Add a translators note for the 'None (top-level comment)' option. - Rename the original parent display variable in comment.js. - Un-nest the parent validation logic in edit_comment(). - Only allow changing the parent when threaded comments are enabled, and enforce the maximum threading depth. - Require the parent to be a comment of the same type, not spam or trashed. - Exclude spam and trashed comments from the parent dropdown and limit it to the 100 most recent comments. - Use wp.i18n.sprintf() to build the parent display string. - Add tests covering the new validations. Co-Authored-By: Claude Fable 5 --- src/js/_enqueues/admin/comment.js | 14 ++- src/wp-admin/edit-form-comment.php | 91 ++++++++++---- src/wp-admin/includes/comment.php | 48 +++++--- .../includes/comment/EditComment_Test.php | 112 ++++++++++++++++++ 4 files changed, 222 insertions(+), 43 deletions(-) diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js index 738bc4dd1e63a..f2c4aa3bd36a5 100644 --- a/src/js/_enqueues/admin/comment.js +++ b/src/js/_enqueues/admin/comment.js @@ -102,7 +102,7 @@ jQuery( function($) { var $commentparentdiv = $( '#comment-parent-div' ), $commentparentdisplay = $( '#comment-parent-display' ), - commentparent = $commentparentdisplay.html(), + originalcommentparentdisplay = $commentparentdisplay.html(), $editcommentparent = $commentparentdiv.siblings( 'a.edit-comment-parent' ); /** @@ -141,7 +141,7 @@ jQuery( function($) { $editcommentparent.show().trigger( 'focus' ); $commentparentdiv.slideUp( 'fast' ); $( '#comment_parent' ).val( $( '#hidden_comment_parent' ).val() ); - $commentparentdisplay.html( commentparent ); + $commentparentdisplay.html( originalcommentparentdisplay ); event.preventDefault(); }); @@ -161,9 +161,13 @@ jQuery( function($) { event.preventDefault(); - $commentparentdisplay - .text( wp.i18n.__( 'In reply to:' ) + ' ' ) - .append( $( '' ).text( parentLabel ) ); + $commentparentdisplay.html( + wp.i18n.sprintf( + /* translators: %s: Parent comment author. */ + wp.i18n.__( 'In reply to: %s' ), + $( '' ).text( parentLabel ).prop( 'outerHTML' ) + ) + ); // Move focus back to the Edit link. $editcommentparent.show().trigger( 'focus' ); diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php index 3dd36994b9778..8f028efd6b400 100644 --- a/src/wp-admin/edit-form-comment.php +++ b/src/wp-admin/edit-form-comment.php @@ -215,34 +215,67 @@ } } -$post_comments = get_comments( - array( - 'post_id' => $comment->comment_post_ID, - 'type' => 'comment', - 'status' => 'all', - 'orderby' => 'comment_date_gmt', - 'order' => 'ASC', - ) -); +// The parent can only be changed when threaded comments are enabled. +$comment_threading_enabled = get_option( 'thread_comments' ); -// Group comments by parent to find this comment's descendants, which cannot become its parent. -$comments_by_parent = array(); +if ( $comment_threading_enabled ) { + $max_thread_depth = (int) get_option( 'thread_comments_depth' ); -foreach ( $post_comments as $post_comment ) { - $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID; -} + // Limit the number of comments to keep memory usage and the size of the dropdown reasonable on busy posts. + $post_comments = get_comments( + array( + 'post_id' => $comment->comment_post_ID, + 'type' => 'comment', + 'status' => array( 'approve', 'hold' ), + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', + 'number' => 100, + ) + ); + + // Restore chronological order for display. + $post_comments = array_reverse( $post_comments ); + + $post_comments_by_id = array(); -$comment_descendants = array(); -$comment_queue = array( (int) $comment->comment_ID ); + foreach ( $post_comments as $post_comment ) { + $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment; + } + + // Group comments by parent to find this comment's descendants, which cannot become its parent. + $comments_by_parent = array(); + + foreach ( $post_comments as $post_comment ) { + $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID; + } -while ( $comment_queue ) { - $descendant_parent = array_shift( $comment_queue ); + $comment_descendants = array(); + $comment_queue = array( (int) $comment->comment_ID ); - if ( isset( $comments_by_parent[ $descendant_parent ] ) ) { - foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) { - $comment_descendants[ $descendant_id ] = true; - $comment_queue[] = $descendant_id; + while ( $comment_queue ) { + $descendant_parent = array_shift( $comment_queue ); + + if ( isset( $comments_by_parent[ $descendant_parent ] ) ) { + foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) { + $comment_descendants[ $descendant_id ] = true; + $comment_queue[] = $descendant_id; + } + } + } + + // Compute each comment's depth, since comments at the maximum threading depth cannot become a parent. + $comment_depths = array(); + + foreach ( $post_comments as $post_comment ) { + $depth = 1; + $ancestor_id = (int) $post_comment->comment_parent; + + while ( $ancestor_id && isset( $post_comments_by_id[ $ancestor_id ] ) ) { + ++$depth; + $ancestor_id = (int) $post_comments_by_id[ $ancestor_id ]->comment_parent; } + + $comment_depths[ (int) $post_comment->comment_ID ] = $depth; } } ?> @@ -256,6 +289,7 @@ ); ?> + ]*>.*?|s', $output, $matches ) ) { + return ''; + } + + return $matches[0]; + } + + /** + * @ticket 65570 + */ + public function test_should_list_valid_parents_and_exclude_invalid_ones() { + $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $top_id, + ) + ); + $spam_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'spam', + ) + ); + $trash_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'trash', + ) + ); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $child_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $comment_id, + ) + ); + + $dropdown = $this->get_parent_dropdown( $comment_id ); + + $this->assertStringContainsString( 'value="0"', $dropdown, 'The "None" option should be listed.' ); + $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' ); + $this->assertStringContainsString( "value='{$reply_id}'", $dropdown, 'A nested comment should be listed.' ); + $this->assertStringNotContainsString( "value='{$comment_id}'", $dropdown, 'The comment being edited should not be listed.' ); + $this->assertStringNotContainsString( "value='{$child_id}'", $dropdown, 'A reply to the comment being edited should not be listed.' ); + $this->assertStringNotContainsString( "value='{$spam_id}'", $dropdown, 'A spam comment should not be listed.' ); + $this->assertStringNotContainsString( "value='{$trash_id}'", $dropdown, 'A trashed comment should not be listed.' ); + } + + /** + * @ticket 65570 + */ + public function test_should_not_render_parent_selector_when_comment_threading_is_disabled() { + update_option( 'thread_comments', 0 ); + + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $output = $this->render_edit_form( $comment_id ); + + $this->assertStringContainsString( 'id="comment-parent-display"', $output, 'The parent should still be displayed.' ); + $this->assertStringNotContainsString( 'name="comment_parent"', $output, 'The parent selector should not be rendered.' ); + } + + /** + * @ticket 65570 + */ + public function test_should_exclude_parents_at_maximum_threading_depth() { + update_option( 'thread_comments_depth', 2 ); + + $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $deep_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $top_id, + ) + ); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $dropdown = $this->get_parent_dropdown( $comment_id ); + + $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' ); + $this->assertStringNotContainsString( "value='{$deep_id}'", $dropdown, 'A comment at the maximum depth should not be listed.' ); + } + + /** + * @ticket 65570 + */ + public function test_should_account_for_replies_when_excluding_deep_parents() { + update_option( 'thread_comments_depth', 3 ); + + $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $mid_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $top_id, + ) + ); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $comment_id, + ) + ); + + $dropdown = $this->get_parent_dropdown( $comment_id ); + + $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A parent leaving room for the reply should be listed.' ); + $this->assertStringNotContainsString( "value='{$mid_id}'", $dropdown, 'A parent whose depth plus the replies would exceed the maximum should not be listed.' ); + } + + /** + * @ticket 65570 + */ + public function test_should_list_current_parent_even_when_it_is_not_a_valid_option() { + $spam_parent_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'spam', + ) + ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $spam_parent_id, + ) + ); + + $dropdown = $this->get_parent_dropdown( $comment_id ); + + $this->assertStringContainsString( "value='{$spam_parent_id}'", $dropdown, 'The current parent should be listed.' ); + $this->assertStringContainsString( ' selected>', $dropdown, 'The current parent should be selected.' ); + } +} From 7d417dbcb96637de407279a30539f3821d707a76 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Wed, 8 Jul 2026 11:37:35 +0200 Subject: [PATCH 7/8] Simplify the comment parent validation guards. Collapse the parent eligibility checks into a single conditional, fold the two depth checks into one (a parent at the maximum depth is a special case of the subtree check), and drop the redundant cycle bookkeeping since the depth bound already terminates the loop. Remove a form test subsumed by the subtree-aware depth test. Co-Authored-By: Claude Fable 5 --- src/wp-admin/edit-form-comment.php | 10 ++--- src/wp-admin/includes/comment.php | 39 +++++++------------ .../tests/admin/EditFormComment_Test.php | 21 ---------- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php index 1401d1e6a7e8a..935a26aee479a 100644 --- a/src/wp-admin/edit-form-comment.php +++ b/src/wp-admin/edit-form-comment.php @@ -236,16 +236,12 @@ // Restore chronological order for display. $post_comments = array_reverse( $post_comments ); + // Index the comments by ID and by parent, to compute depths and find descendants. $post_comments_by_id = array(); + $comments_by_parent = array(); foreach ( $post_comments as $post_comment ) { - $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment; - } - - // Group comments by parent to find this comment's descendants, which cannot become its parent. - $comments_by_parent = array(); - - foreach ( $post_comments as $post_comment ) { + $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment; $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID; } diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index aa8358e3a8898..68bd6c6042b6d 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -94,16 +94,14 @@ function edit_comment() { $parent = get_comment( $comment_parent ); - if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) { - return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) ); - } - - if ( $parent->comment_type !== $comment->comment_type ) { - return new WP_Error( 'comment_parent_invalid', __( 'The parent must be a comment of the same type.' ) ); - } - - if ( in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true ) ) { - return new WP_Error( 'comment_parent_invalid', __( 'The parent cannot be a comment in the Trash or marked as spam.' ) ); + // The parent must be a comment of the same type, on the same post, and not in the Trash or marked as spam. + if ( + ! $parent + || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID + || $parent->comment_type !== $comment->comment_type + || in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true ) + ) { + return new WP_Error( 'comment_parent_invalid', __( 'Invalid parent comment.' ) ); } // Walk up the new parent's ancestors to prevent creating a threading loop. @@ -125,13 +123,13 @@ function edit_comment() { $max_thread_depth = (int) get_option( 'thread_comments_depth' ); if ( $max_thread_depth ) { - if ( $parent_depth >= $max_thread_depth ) { - return new WP_Error( 'comment_parent_invalid', __( 'The parent comment is already at the maximum threading depth.' ) ); - } - - // The comment's replies move with it, so the whole subtree must stay within the maximum depth. + /* + * The comment's replies move with it, so the whole subtree must stay within + * the maximum depth. Measure its height one level of replies at a time, + * stopping as soon as the subtree cannot fit, which also bounds the loop + * should the stored comment hierarchy contain a cycle. + */ $subtree_height = 1; - $subtree_ids = array( $comment_id => true ); $level_ids = array( $comment_id ); while ( $level_ids && $parent_depth + $subtree_height <= $max_thread_depth ) { @@ -144,20 +142,13 @@ function edit_comment() { ) ); - // Guard against a comment appearing twice, e.g. a loop in existing data. - $level_ids = array_diff( array_map( 'intval', $level_ids ), array_keys( $subtree_ids ) ); - - foreach ( $level_ids as $level_id ) { - $subtree_ids[ $level_id ] = true; - } - if ( $level_ids ) { ++$subtree_height; } } if ( $parent_depth + $subtree_height > $max_thread_depth ) { - return new WP_Error( 'comment_parent_invalid', __( 'The replies to this comment would exceed the maximum threading depth.' ) ); + return new WP_Error( 'comment_parent_invalid', __( 'The comment cannot be moved there because it or its replies would exceed the maximum threading depth.' ) ); } } } diff --git a/tests/phpunit/tests/admin/EditFormComment_Test.php b/tests/phpunit/tests/admin/EditFormComment_Test.php index 2bc7853f3e212..f75785b0495e9 100644 --- a/tests/phpunit/tests/admin/EditFormComment_Test.php +++ b/tests/phpunit/tests/admin/EditFormComment_Test.php @@ -129,27 +129,6 @@ public function test_should_not_render_parent_selector_when_comment_threading_is $this->assertStringNotContainsString( 'name="comment_parent"', $output, 'The parent selector should not be rendered.' ); } - /** - * @ticket 65570 - */ - public function test_should_exclude_parents_at_maximum_threading_depth() { - update_option( 'thread_comments_depth', 2 ); - - $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); - $deep_id = self::factory()->comment->create( - array( - 'comment_post_ID' => self::$post_id, - 'comment_parent' => $top_id, - ) - ); - $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); - - $dropdown = $this->get_parent_dropdown( $comment_id ); - - $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' ); - $this->assertStringNotContainsString( "value='{$deep_id}'", $dropdown, 'A comment at the maximum depth should not be listed.' ); - } - /** * @ticket 65570 */ From e3aca1c4f33373f1bec2023ed3afe4d524eca5e2 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 9 Jul 2026 11:02:00 +0200 Subject: [PATCH 8/8] Show 'None' in the parent display when a comment's parent is removed. Match the label the Edit Comment screen renders on load, and align the translators comment for the 'In reply to' string with the PHP side. Props westonruter. Co-Authored-By: Claude Fable 5 --- src/js/_enqueues/admin/comment.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js index f2c4aa3bd36a5..b31f174bab5fe 100644 --- a/src/js/_enqueues/admin/comment.js +++ b/src/js/_enqueues/admin/comment.js @@ -157,13 +157,16 @@ jQuery( function($) { */ $commentparentdiv.find( '.save-comment-parent' ).on( 'click', function( event ) { var $selected = $( '#comment_parent option:selected' ), - parentLabel = $selected.data( 'author' ) || $selected.text(); + parentLabel = '0' === $selected.val() ? + /* translators: Displayed when a comment has no parent. */ + wp.i18n.__( 'None' ) : + $selected.data( 'author' ) || $selected.text(); event.preventDefault(); $commentparentdisplay.html( wp.i18n.sprintf( - /* translators: %s: Parent comment author. */ + /* translators: %s: Parent comment link, or 'None'. */ wp.i18n.__( 'In reply to: %s' ), $( '' ).text( parentLabel ).prop( 'outerHTML' ) )