Skip to content

Commit bb30a9b

Browse files
committed
Administration: Show part of the excerpt for untitled posts in the list table Compact view.
Posts and pages with no title previously displayed only "(no title)" in the posts list table, making untitled items hard to tell apart. When a post has no title, this appends up to 15 words of its trimmed excerpt after the title link in the Compact (list) view. The excerpt is only shown when the Excerpt view is not active, the post has no title, no password is required, and the current user can read the post. The checkbox and locked-post accessibility labels are updated to match the visible link text, and optional styling is added for the trimmed excerpt. Props sabernhardt, mcsf, tyxla, annezazu, ramonopoly, joen, wildworks, joedolson, jeffpaul, matt. Fixes #65022. git-svn-id: https://develop.svn.wordpress.org/trunk@62685 602fd350-edb4-49c9-b593-d223f7449a82
1 parent b5ba245 commit bb30a9b

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)