Skip to content

Commit 3ec6c92

Browse files
committed
Comments: Allow changing a comment's parent from the Edit Comment screen.
Add a parent selector to the misc actions section of the Edit Comment screen, so a comment can be moved to a different thread or made top-level without editing the database directly. The selector is only rendered when comment threading is enabled. It lists the post's approved and pending comments, capped at the 100 most recent, and omits comments that cannot become the parent. Props matt, mcsf, tyxla, westonruter, youknowriad. Fixes #65570. git-svn-id: https://develop.svn.wordpress.org/trunk@62673 602fd350-edb4-49c9-b593-d223f7449a82
1 parent fb532ba commit 3ec6c92

6 files changed

Lines changed: 877 additions & 15 deletions

File tree

src/js/_enqueues/admin/comment.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,81 @@ jQuery( function($) {
9999
$edittimestamp.show().trigger( 'focus' );
100100
$timestampdiv.slideUp( 'fast' );
101101
});
102+
103+
var $commentparentdiv = $( '#comment-parent-div' ),
104+
$commentparentdisplay = $( '#comment-parent-display' ),
105+
originalcommentparentdisplay = $commentparentdisplay.html(),
106+
$editcommentparent = $commentparentdiv.siblings( 'a.edit-comment-parent' );
107+
108+
/**
109+
* Adds event that opens the parent comment form if the form is hidden.
110+
*
111+
* @since 7.1.0
112+
*
113+
* @listens $editcommentparent:click
114+
*
115+
* @param {Event} event The event object.
116+
* @return {void}
117+
*/
118+
$editcommentparent.on( 'click', function( event ) {
119+
if ( $commentparentdiv.is( ':hidden' ) ) {
120+
// Slide down the form and set focus on the parent field.
121+
$commentparentdiv.slideDown( 'fast', function() {
122+
$( '#comment_parent' ).trigger( 'focus' );
123+
} );
124+
$(this).hide();
125+
}
126+
event.preventDefault();
127+
});
128+
129+
/**
130+
* Resets the parent comment value when the cancel button is clicked.
131+
*
132+
* @since 7.1.0
133+
*
134+
* @listens .cancel-comment-parent:click
135+
*
136+
* @param {Event} event The event object.
137+
* @return {void}
138+
*/
139+
$commentparentdiv.find( '.cancel-comment-parent' ).on( 'click', function( event ) {
140+
// Move focus back to the Edit link.
141+
$editcommentparent.show().trigger( 'focus' );
142+
$commentparentdiv.slideUp( 'fast' );
143+
$( '#comment_parent' ).val( $( '#hidden_comment_parent' ).val() );
144+
$commentparentdisplay.html( originalcommentparentdisplay );
145+
event.preventDefault();
146+
});
147+
148+
/**
149+
* Updates the parent comment display when the ok button is clicked.
150+
*
151+
* @since 7.1.0
152+
*
153+
* @listens .save-comment-parent:click
154+
*
155+
* @param {Event} event The event object.
156+
* @return {void}
157+
*/
158+
$commentparentdiv.find( '.save-comment-parent' ).on( 'click', function( event ) {
159+
var $selected = $( '#comment_parent option:selected' ),
160+
parentLabel = '0' === $selected.val() ?
161+
/* translators: Displayed when a comment has no parent. */
162+
wp.i18n.__( 'None' ) :
163+
$selected.data( 'author' ) || $selected.text();
164+
165+
event.preventDefault();
166+
167+
$commentparentdisplay.html(
168+
wp.i18n.sprintf(
169+
/* translators: %s: Parent comment link, or 'None'. */
170+
wp.i18n.__( 'In reply to: %s' ),
171+
$( '<b />' ).text( parentLabel ).prop( 'outerHTML' )
172+
)
173+
);
174+
175+
// Move focus back to the Edit link.
176+
$editcommentparent.show().trigger( 'focus' );
177+
$commentparentdiv.slideUp( 'fast' );
178+
});
102179
});

src/wp-admin/css/edit.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,16 @@ form#tags-filter {
554554
line-height: 1.76923076;
555555
}
556556

557+
#comment-parent-div {
558+
padding-top: 5px;
559+
/* Fieldsets cannot shrink below their content by default, allow it so the select cannot cause overflow. */
560+
min-width: 0;
561+
}
562+
563+
#comment-parent-div select {
564+
width: 100%;
565+
}
566+
557567
#timestampdiv p {
558568
margin: 8px 0 6px;
559569
}

src/wp-admin/edit-form-comment.php

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,25 +201,183 @@
201201
</div>
202202

203203
<?php
204-
if ( $comment->comment_parent ) :
204+
$parent_display = __( 'None' );
205+
206+
if ( $comment->comment_parent ) {
205207
$parent = get_comment( $comment->comment_parent );
206-
if ( $parent ) :
207-
$parent_link = esc_url( get_comment_link( $parent ) );
208-
$name = get_comment_author( $parent );
209-
?>
210-
<div class="misc-pub-section misc-pub-reply-to">
208+
209+
if ( $parent ) {
210+
$parent_display = sprintf(
211+
'<a href="%s">%s</a>',
212+
esc_url( get_comment_link( $parent ) ),
213+
esc_html( get_comment_author( $parent ) )
214+
);
215+
}
216+
}
217+
218+
// The parent can only be changed when threaded comments are enabled.
219+
$comment_threading_enabled = get_option( 'thread_comments' );
220+
221+
if ( $comment_threading_enabled ) {
222+
$max_thread_depth = (int) get_option( 'thread_comments_depth' );
223+
224+
// Limit the number of comments to keep memory usage and the size of the dropdown reasonable on busy posts.
225+
$post_comments = get_comments(
226+
array(
227+
'post_id' => $comment->comment_post_ID,
228+
'type' => 'comment',
229+
'status' => array( 'approve', 'hold' ),
230+
'orderby' => 'comment_date_gmt',
231+
'order' => 'DESC',
232+
'number' => 100,
233+
)
234+
);
235+
236+
// Restore chronological order for display.
237+
$post_comments = array_reverse( $post_comments );
238+
239+
// Index the comments by ID and by parent, to compute depths and find descendants.
240+
$post_comments_by_id = array();
241+
$comments_by_parent = array();
242+
243+
foreach ( $post_comments as $post_comment ) {
244+
$post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment;
245+
$comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
246+
}
247+
248+
// Walk the descendants level by level, which also gives the height of the subtree that moves with the comment.
249+
$comment_descendants = array();
250+
$comment_subtree_height = 1;
251+
$comment_level = array( (int) $comment->comment_ID );
252+
253+
while ( $comment_level ) {
254+
$next_level = array();
255+
256+
foreach ( $comment_level as $level_comment_id ) {
257+
if ( isset( $comments_by_parent[ $level_comment_id ] ) ) {
258+
foreach ( $comments_by_parent[ $level_comment_id ] as $descendant_id ) {
259+
$comment_descendants[ $descendant_id ] = true;
260+
$next_level[] = $descendant_id;
261+
}
262+
}
263+
}
264+
265+
if ( $next_level ) {
266+
++$comment_subtree_height;
267+
}
268+
269+
$comment_level = $next_level;
270+
}
271+
272+
// Compute each comment's depth, since comments at the maximum threading depth cannot become a parent.
273+
$comment_depths = array();
274+
275+
foreach ( $post_comments as $post_comment ) {
276+
$depth = 1;
277+
$ancestor_id = (int) $post_comment->comment_parent;
278+
279+
while ( $ancestor_id && isset( $post_comments_by_id[ $ancestor_id ] ) ) {
280+
++$depth;
281+
$ancestor_id = (int) $post_comments_by_id[ $ancestor_id ]->comment_parent;
282+
}
283+
284+
$comment_depths[ (int) $post_comment->comment_ID ] = $depth;
285+
}
286+
}
287+
?>
288+
<div class="misc-pub-section misc-pub-reply-to">
289+
<span id="comment-parent-display">
290+
<?php
291+
printf(
292+
/* translators: %s: Parent comment link, or 'None'. */
293+
__( 'In reply to: %s' ),
294+
'<b>' . $parent_display . '</b>'
295+
);
296+
?>
297+
</span>
298+
<?php if ( $comment_threading_enabled ) : ?>
299+
<a href="#edit_comment_parent" class="edit-comment-parent hide-if-no-js"><span aria-hidden="true"><?php _e( 'Edit' ); ?></span> <span class="screen-reader-text">
300+
<?php
301+
/* translators: Hidden accessibility text. */
302+
_e( 'Edit parent comment' );
303+
?>
304+
</span></a>
305+
<fieldset id="comment-parent-div" class="hide-if-js">
306+
<legend class="screen-reader-text">
307+
<?php
308+
/* translators: Hidden accessibility text. */
309+
_e( 'Parent comment' );
310+
?>
311+
</legend>
312+
<label for="comment_parent"><?php _e( 'Parent comment' ); ?></label>
313+
<select name="comment_parent" id="comment_parent">
314+
<option value="0"<?php selected( 0, (int) $comment->comment_parent ); ?>>
211315
<?php
316+
/* translators: Option in the parent comment dropdown, meaning the comment has no parent. */
317+
_e( 'None (top-level comment)' );
318+
?>
319+
</option>
320+
<?php
321+
$current_parent_listed = false;
322+
323+
foreach ( $post_comments as $post_comment ) {
324+
$post_comment_id = (int) $post_comment->comment_ID;
325+
326+
if ( $post_comment_id === (int) $comment->comment_ID || isset( $comment_descendants[ $post_comment_id ] ) ) {
327+
continue;
328+
}
329+
330+
// The comment and the replies that move with it must stay within the maximum threading depth.
331+
if ( $max_thread_depth && $comment_depths[ $post_comment_id ] + $comment_subtree_height > $max_thread_depth ) {
332+
continue;
333+
}
334+
335+
if ( $post_comment_id === (int) $comment->comment_parent ) {
336+
$current_parent_listed = true;
337+
}
338+
339+
$option_label = sprintf(
340+
/* translators: 1: Comment author, 2: Comment excerpt. */
341+
__( '%1$s: %2$s' ),
342+
get_comment_author( $post_comment ),
343+
wp_html_excerpt( get_comment_excerpt( $post_comment ), 50, '' )
344+
);
345+
212346
printf(
213-
/* translators: %s: Comment link. */
214-
__( 'In reply to: %s' ),
215-
'<b><a href="' . $parent_link . '">' . $name . '</a></b>'
347+
"\t<option value='%d' data-author='%s'%s>%s</option>\n",
348+
$post_comment_id,
349+
esc_attr( get_comment_author( $post_comment ) ),
350+
selected( $post_comment_id, (int) $comment->comment_parent, false ),
351+
esc_html( $option_label )
216352
);
217-
?>
218-
</div>
219-
<?php
220-
endif;
221-
endif;
222-
?>
353+
}
354+
355+
// The current parent may not be listed, e.g. a pingback or a comment no longer publicly visible.
356+
if ( $comment->comment_parent && ! $current_parent_listed && isset( $parent ) ) {
357+
$option_label = sprintf(
358+
/* translators: 1: Comment author, 2: Comment excerpt. */
359+
__( '%1$s: %2$s' ),
360+
get_comment_author( $parent ),
361+
wp_html_excerpt( get_comment_excerpt( $parent ), 50, '' )
362+
);
363+
364+
printf(
365+
"\t<option value='%d' data-author='%s' selected>%s</option>\n",
366+
(int) $comment->comment_parent,
367+
esc_attr( get_comment_author( $parent ) ),
368+
esc_html( $option_label )
369+
);
370+
}
371+
?>
372+
</select>
373+
<input type="hidden" id="hidden_comment_parent" value="<?php echo esc_attr( $comment->comment_parent ); ?>" />
374+
<p>
375+
<a href="#edit_comment_parent" class="save-comment-parent hide-if-no-js button"><?php _e( 'OK' ); ?></a>
376+
<a href="#edit_comment_parent" class="cancel-comment-parent hide-if-no-js button-cancel"><?php _e( 'Cancel' ); ?></a>
377+
</p>
378+
</fieldset>
379+
<?php endif; ?>
380+
</div>
223381

224382
<?php
225383
/**

src/wp-admin/includes/comment.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function comment_exists( $comment_author, $comment_date, $timezone = 'blog' ) {
4646
*
4747
* @since 2.0.0
4848
* @since 5.5.0 A return value was added.
49+
* @since 7.1.0 The comment parent can be updated via `$_POST['comment_parent']`.
4950
*
5051
* @return int|WP_Error The value 1 if the comment was updated, 0 if not updated.
5152
* A WP_Error object on failure.
@@ -74,6 +75,85 @@ function edit_comment() {
7475
$_POST['comment_ID'] = (int) $_POST['comment_ID'];
7576
}
7677

78+
if ( isset( $_POST['comment_parent'] ) ) {
79+
$comment_id = (int) $_POST['comment_ID'];
80+
$comment_parent = (int) $_POST['comment_parent'];
81+
82+
$_POST['comment_parent'] = $comment_parent;
83+
84+
$comment = get_comment( $comment_id );
85+
86+
if ( $comment && $comment_parent && $comment_parent !== (int) $comment->comment_parent ) {
87+
if ( ! get_option( 'thread_comments' ) ) {
88+
return new WP_Error( 'comment_parent_invalid', __( 'The comment parent cannot be changed because threaded comments are disabled.' ) );
89+
}
90+
91+
if ( $comment_parent === $comment_id ) {
92+
return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to itself.' ) );
93+
}
94+
95+
$parent = get_comment( $comment_parent );
96+
97+
// The parent must be a comment of the same type, on the same post, and not in the Trash or marked as spam.
98+
if (
99+
! $parent
100+
|| (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID
101+
|| $parent->comment_type !== $comment->comment_type
102+
|| in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true )
103+
) {
104+
return new WP_Error( 'comment_parent_invalid', __( 'Invalid parent comment.' ) );
105+
}
106+
107+
// Walk up the new parent's ancestors to prevent creating a threading loop.
108+
$ancestors = array();
109+
$ancestor = $parent;
110+
$parent_depth = 1;
111+
112+
while ( $ancestor && $ancestor->comment_parent && ! isset( $ancestors[ $ancestor->comment_ID ] ) ) {
113+
if ( (int) $ancestor->comment_parent === $comment_id ) {
114+
return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to one of its own replies.' ) );
115+
}
116+
117+
$ancestors[ $ancestor->comment_ID ] = true;
118+
++$parent_depth;
119+
120+
$ancestor = get_comment( $ancestor->comment_parent );
121+
}
122+
123+
$max_thread_depth = (int) get_option( 'thread_comments_depth' );
124+
125+
if ( $max_thread_depth ) {
126+
/*
127+
* The comment's replies move with it, so the whole subtree must stay within
128+
* the maximum depth. Measure its height one level of replies at a time,
129+
* stopping as soon as the subtree cannot fit, which also bounds the loop
130+
* should the stored comment hierarchy contain a cycle.
131+
*/
132+
$subtree_height = 1;
133+
$level_ids = array( $comment_id );
134+
135+
while ( $level_ids && $parent_depth + $subtree_height <= $max_thread_depth ) {
136+
$level_ids = get_comments(
137+
array(
138+
'parent__in' => $level_ids,
139+
'fields' => 'ids',
140+
'status' => 'any',
141+
'orderby' => 'none',
142+
)
143+
);
144+
145+
if ( $level_ids ) {
146+
++$subtree_height;
147+
}
148+
}
149+
150+
if ( $parent_depth + $subtree_height > $max_thread_depth ) {
151+
return new WP_Error( 'comment_parent_invalid', __( 'The comment cannot be moved there because it or its replies would exceed the maximum threading depth.' ) );
152+
}
153+
}
154+
}
155+
}
156+
77157
foreach ( array( 'aa', 'mm', 'jj', 'hh', 'mn' ) as $timeunit ) {
78158
if ( ! empty( $_POST[ 'hidden_' . $timeunit ] ) && $_POST[ 'hidden_' . $timeunit ] !== $_POST[ $timeunit ] ) {
79159
$_POST['edit_date'] = '1';

0 commit comments

Comments
 (0)