Skip to content

Commit fc72c25

Browse files
authored
Merge branch 'WordPress:trunk' into trunk
2 parents 4910777 + bb30a9b commit fc72c25

3 files changed

Lines changed: 245 additions & 2 deletions

File tree

src/wp-admin/css/list-tables.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ td.plugin-title p {
363363
margin: 6px 0;
364364
}
365365

366+
.trimmed-post-excerpt {
367+
font-weight: 400;
368+
}
369+
366370
/* Media file column */
367371
table.media .column-title .media-icon {
368372
float: left;

src/wp-admin/includes/class-wp-posts-list-table.php

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,11 +1012,45 @@ private function _page_rows( &$children_pages, &$count, $parent_page, $level, $p
10121012
unset( $children_pages[ $parent_page ] ); // Required in order to keep track of orphans.
10131013
}
10141014

1015+
/**
1016+
* Gets a trimmed excerpt to display in place of a missing post title.
1017+
*
1018+
* Only returns text in the Compact list view for posts that have no title,
1019+
* do not require a password, and that the current user is allowed to read.
1020+
*
1021+
* @since 7.1.0
1022+
*
1023+
* @global string $mode List table view mode.
1024+
*
1025+
* @param WP_Post $post The current WP_Post object.
1026+
* @return string The escaped, trimmed excerpt, or an empty string.
1027+
*/
1028+
protected function get_no_title_excerpt( $post ) {
1029+
global $mode;
1030+
1031+
if ( 'excerpt' === $mode
1032+
|| '' !== get_the_title( $post )
1033+
|| post_password_required( $post )
1034+
|| ! current_user_can( 'read_post', $post->ID )
1035+
) {
1036+
return '';
1037+
}
1038+
1039+
$excerpt = get_the_excerpt( $post );
1040+
1041+
if ( '' === $excerpt || ! is_string( $excerpt ) ) {
1042+
return '';
1043+
}
1044+
1045+
return esc_html( wp_trim_words( $excerpt, 15 ) );
1046+
}
1047+
10151048
/**
10161049
* Handles the checkbox column output.
10171050
*
10181051
* @since 4.3.0
10191052
* @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
1053+
* @since 7.1.0 Includes a trimmed excerpt for untitled posts in Compact view.
10201054
*
10211055
* @param WP_Post $item The current WP_Post object.
10221056
*/
@@ -1037,13 +1071,21 @@ public function column_cb( $item ) {
10371071
* @param WP_Post $post The current WP_Post object.
10381072
*/
10391073
if ( apply_filters( 'wp_list_table_show_post_checkbox', $show, $post ) ) :
1074+
1075+
$post_title = _draft_or_post_title();
1076+
1077+
// If the post has no title, try adding part of the excerpt.
1078+
$no_title_excerpt = $this->get_no_title_excerpt( $post );
1079+
if ( '' !== $no_title_excerpt ) {
1080+
$post_title .= ' ' . $no_title_excerpt;
1081+
}
10401082
?>
10411083
<input id="cb-select-<?php the_ID(); ?>" type="checkbox" name="post[]" value="<?php the_ID(); ?>" />
10421084
<label for="cb-select-<?php the_ID(); ?>">
10431085
<span class="screen-reader-text">
10441086
<?php
10451087
/* translators: %s: Post title. */
1046-
printf( __( 'Select %s' ), _draft_or_post_title() );
1088+
printf( __( 'Select %s' ), $post_title );
10471089
?>
10481090
</span>
10491091
</label>
@@ -1054,7 +1096,7 @@ public function column_cb( $item ) {
10541096
printf(
10551097
/* translators: Hidden accessibility text. %s: Post title. */
10561098
__( '&#8220;%s&#8221; is locked' ),
1057-
_draft_or_post_title()
1099+
$post_title
10581100
);
10591101
?>
10601102
</span>
@@ -1082,6 +1124,7 @@ protected function _column_title( $post, $classes, $data, $primary ) {
10821124
* Handles the title column output.
10831125
*
10841126
* @since 4.3.0
1127+
* @since 7.1.0 Includes a trimmed excerpt for untitled posts in Compact view.
10851128
*
10861129
* @global string $mode List table view mode.
10871130
*
@@ -1136,6 +1179,12 @@ public function column_title( $post ) {
11361179

11371180
$title = _draft_or_post_title();
11381181

1182+
// If the post has no title, try adding part of the excerpt.
1183+
$no_title_excerpt = $this->get_no_title_excerpt( $post );
1184+
if ( '' !== $no_title_excerpt ) {
1185+
$title .= ' <span class="trimmed-post-excerpt">' . $no_title_excerpt . '</span>';
1186+
}
1187+
11391188
if ( $can_edit_post && 'trash' !== $post->post_status ) {
11401189
printf(
11411190
'<a class="row-title" href="%s">%s%s</a>',

tests/phpunit/tests/admin/wpPostsListTable.php

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,4 +330,194 @@ public function test_get_views_should_return_views_by_default() {
330330

331331
$this->assertSame( $expected, $actual );
332332
}
333+
334+
/**
335+
* Renders the title column for a post in a given list view mode.
336+
*
337+
* @param WP_Post $post The post to render.
338+
* @param string $mode_value The list view mode ('list' or 'excerpt').
339+
* @return string The rendered output.
340+
*/
341+
protected function render_column_title( $post, $mode_value ) {
342+
global $mode;
343+
$mode = $mode_value;
344+
345+
$table = _get_list_table( 'WP_Posts_List_Table', array( 'screen' => 'edit-post' ) );
346+
$table->set_hierarchical_display( false );
347+
348+
ob_start();
349+
$table->column_title( $post );
350+
return ob_get_clean();
351+
}
352+
353+
/**
354+
* @ticket 65022
355+
*
356+
* @dataProvider data_no_title_excerpt_visibility
357+
*
358+
* @covers WP_Posts_List_Table::column_title
359+
* @covers WP_Posts_List_Table::get_no_title_excerpt
360+
*
361+
* @param string $mode_value The list view mode ('list' or 'excerpt').
362+
* @param array $post_args Overrides for the post to create.
363+
* @param bool $should_show Whether the trimmed excerpt should be shown.
364+
*/
365+
public function test_no_title_excerpt_visibility( $mode_value, $post_args, $should_show ) {
366+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
367+
368+
$post = self::factory()->post->create_and_get(
369+
array_merge(
370+
array(
371+
'post_type' => 'post',
372+
'post_status' => 'publish',
373+
'post_title' => '',
374+
'post_excerpt' => 'Alpha beta gamma delta epsilon.',
375+
),
376+
$post_args
377+
)
378+
);
379+
380+
$output = $this->render_column_title( $post, $mode_value );
381+
382+
if ( $should_show ) {
383+
$this->assertStringContainsString( 'class="trimmed-post-excerpt"', $output );
384+
} else {
385+
$this->assertStringNotContainsString( 'class="trimmed-post-excerpt"', $output );
386+
}
387+
}
388+
389+
/**
390+
* Data provider.
391+
*
392+
* @return array[]
393+
*/
394+
public function data_no_title_excerpt_visibility() {
395+
return array(
396+
'compact view, untitled' => array( 'list', array(), true ),
397+
'extended view, untitled' => array( 'excerpt', array(), false ),
398+
'compact view, has title' => array( 'list', array( 'post_title' => 'A real title' ), false ),
399+
'compact view, password' => array( 'list', array( 'post_password' => 'secret' ), false ),
400+
);
401+
}
402+
403+
/**
404+
* @ticket 65022
405+
*
406+
* @covers WP_Posts_List_Table::column_title
407+
* @covers WP_Posts_List_Table::get_no_title_excerpt
408+
*/
409+
public function test_no_title_excerpt_is_trimmed_to_15_words() {
410+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
411+
412+
$words = array();
413+
for ( $n = 1; $n <= 20; $n++ ) {
414+
$words[] = 'word' . $n;
415+
}
416+
417+
$post = self::factory()->post->create_and_get(
418+
array(
419+
'post_title' => '',
420+
'post_excerpt' => implode( ' ', $words ),
421+
'post_status' => 'publish',
422+
)
423+
);
424+
425+
$output = $this->render_column_title( $post, 'list' );
426+
427+
$this->assertStringContainsString( 'word15', $output, 'The 15th word should be present.' );
428+
$this->assertStringNotContainsString( 'word16', $output, 'The 16th word should be trimmed.' );
429+
$this->assertStringContainsString( '&hellip;', $output, 'The excerpt should end with an ellipsis.' );
430+
}
431+
432+
/**
433+
* Ensures excerpt output is escaped, including markup reintroduced by the `wp_trim_words` filter.
434+
*
435+
* @ticket 65022
436+
*
437+
* @covers WP_Posts_List_Table::column_title
438+
* @covers WP_Posts_List_Table::get_no_title_excerpt
439+
*/
440+
public function test_no_title_excerpt_is_escaped() {
441+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
442+
443+
$post = self::factory()->post->create_and_get(
444+
array(
445+
'post_title' => '',
446+
'post_excerpt' => 'Some excerpt text.',
447+
'post_status' => 'publish',
448+
)
449+
);
450+
451+
add_filter( 'wp_trim_words', array( $this, 'filter_wp_trim_words_return_markup' ) );
452+
$output = $this->render_column_title( $post, 'list' );
453+
remove_filter( 'wp_trim_words', array( $this, 'filter_wp_trim_words_return_markup' ) );
454+
455+
$this->assertStringContainsString( '&lt;script&gt;alert(1)&lt;/script&gt;', $output, 'The markup should be escaped.' );
456+
$this->assertStringNotContainsString( '<script>alert(1)</script>', $output, 'Raw markup should not be output.' );
457+
}
458+
459+
/**
460+
* Filter callback returning markup for the `wp_trim_words` filter.
461+
*
462+
* @return string
463+
*/
464+
public function filter_wp_trim_words_return_markup() {
465+
return '<script>alert(1)</script>';
466+
}
467+
468+
/**
469+
* @ticket 65022
470+
*
471+
* @covers WP_Posts_List_Table::get_no_title_excerpt
472+
*/
473+
public function test_no_title_excerpt_hidden_when_user_cannot_read_post() {
474+
$author = self::factory()->user->create( array( 'role' => 'administrator' ) );
475+
476+
$post = self::factory()->post->create_and_get(
477+
array(
478+
'post_author' => $author,
479+
'post_title' => '',
480+
'post_excerpt' => 'Private draft excerpt.',
481+
'post_status' => 'draft',
482+
)
483+
);
484+
485+
// A subscriber cannot read another user's draft.
486+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
487+
488+
$output = $this->render_column_title( $post, 'list' );
489+
490+
$this->assertStringNotContainsString( 'class="trimmed-post-excerpt"', $output );
491+
}
492+
493+
/**
494+
* @ticket 65022
495+
*
496+
* @covers WP_Posts_List_Table::column_cb
497+
* @covers WP_Posts_List_Table::get_no_title_excerpt
498+
*/
499+
public function test_checkbox_label_includes_no_title_excerpt() {
500+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
501+
502+
global $mode;
503+
$mode = 'list';
504+
505+
$post = self::factory()->post->create_and_get(
506+
array(
507+
'post_title' => '',
508+
'post_excerpt' => 'Hello world example excerpt.',
509+
'post_status' => 'publish',
510+
)
511+
);
512+
513+
$GLOBALS['post'] = $post;
514+
515+
$table = _get_list_table( 'WP_Posts_List_Table', array( 'screen' => 'edit-post' ) );
516+
517+
ob_start();
518+
$table->column_cb( $post );
519+
$output = ob_get_clean();
520+
521+
$this->assertStringContainsString( 'Select (no title) Hello world example excerpt.', $output );
522+
}
333523
}

0 commit comments

Comments
 (0)