Make WordPress Core

Changeset 62685


Ignore:
Timestamp:
07/10/2026 12:03:55 PM (5 weeks ago)
Author:
tyxla
Message:

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.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/css/list-tables.css

    r62516 r62685  
    364364}
    365365
     366.trimmed-post-excerpt {
     367        font-weight: 400;
     368}
     369
    366370/* Media file column */
    367371table.media .column-title .media-icon {
  • trunk/src/wp-admin/includes/class-wp-posts-list-table.php

    r62400 r62685  
    10141014
    10151015        /**
     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
     1048        /**
    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.
     
    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(); ?>" />
     
    10441086                                <?php
    10451087                                        /* translators: %s: Post title. */
    1046                                         printf( __( 'Select %s' ), _draft_or_post_title() );
     1088                                        printf( __( 'Select %s' ), $post_title );
    10471089                                ?>
    10481090                                </span>
     
    10551097                                        /* translators: Hidden accessibility text. %s: Post title. */
    10561098                                        __( '&#8220;%s&#8221; is locked' ),
    1057                                         _draft_or_post_title()
     1099                                        $post_title
    10581100                                );
    10591101                                ?>
     
    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.
     
    11361179
    11371180                $title = _draft_or_post_title();
     1181
     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                }
    11381187
    11391188                if ( $can_edit_post && 'trash' !== $post->post_status ) {
  • trunk/tests/phpunit/tests/admin/wpPostsListTable.php

    r56547 r62685  
    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}
Note: See TracChangeset for help on using the changeset viewer.