Skip to content

Commit f1c7186

Browse files
Plugins: add a native "Filter by author" control to the plugins list.
Add a single author dropdown (backed by a new plugin_author query var) populated from installed plugins' Author headers, so plugins can be filtered by author without each author injecting a top-level status tab. The grouping is filterable via plugins_list_authors and plugins_list_plugin_author. Includes unit tests. Follow-up to [61695] / #60495. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6a1aad commit f1c7186

4 files changed

Lines changed: 592 additions & 1 deletion

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,11 @@ th.sorted a span {
729729
padding: 0 8px 0 0;
730730
}
731731

732+
/* Let the "Filter by author" select grow with its options, up to a sensible cap. */
733+
#plugin-author-filter {
734+
max-width: 25rem;
735+
}
736+
732737
.wp-filter .actions {
733738
display: inline-block;
734739
vertical-align: middle;

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

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ class WP_Plugins_List_Table extends WP_List_Table {
2424
*/
2525
protected $show_autoupdates = true;
2626

27+
/**
28+
* Plugin authors for the "Filter by author" control, keyed by author key.
29+
*
30+
* Each value is an array containing a 'label' and a 'count'.
31+
*
32+
* @since 7.1.0
33+
*
34+
* @var array
35+
*/
36+
protected $plugin_authors = array();
37+
2738
/**
2839
* Constructor.
2940
*
@@ -311,6 +322,36 @@ public function prepare_items() {
311322
$totals[ $type ] = count( $list );
312323
}
313324

325+
// Build the list of plugin authors for the "Filter by author" control.
326+
$this->plugin_authors = array();
327+
foreach ( $plugins['all'] as $plugin_file => $plugin_data ) {
328+
$author_key = $this->get_plugin_author_key( $plugin_file, $plugin_data );
329+
if ( '' === $author_key ) {
330+
continue;
331+
}
332+
if ( ! isset( $this->plugin_authors[ $author_key ] ) ) {
333+
$this->plugin_authors[ $author_key ] = array(
334+
'label' => ( isset( $plugin_data['AuthorName'] ) && '' !== $plugin_data['AuthorName'] ) ? $plugin_data['AuthorName'] : $author_key,
335+
'count' => 0,
336+
);
337+
}
338+
++$this->plugin_authors[ $author_key ]['count'];
339+
}
340+
341+
/**
342+
* Filters the authors shown in the Plugins list table "Filter by author" control.
343+
*
344+
* The array is keyed by author key; each value is an array with a 'label' and a 'count'.
345+
* Returning the same key for several plugins merges author-name variants, while removing
346+
* an entry hides that author from the control.
347+
*
348+
* @since 7.1.0
349+
*
350+
* @param array $plugin_authors Map of author key to an array with 'label' and 'count'.
351+
* @param array $all_plugins The full list of installed plugins, keyed by plugin file.
352+
*/
353+
$this->plugin_authors = apply_filters( 'plugins_list_authors', $this->plugin_authors, $plugins['all'] );
354+
314355
if ( empty( $plugins[ $status ] ) && ! in_array( $status, array( 'all', 'search' ), true ) ) {
315356
$status = 'all';
316357
}
@@ -321,7 +362,17 @@ public function prepare_items() {
321362
$this->items[ $plugin_file ] = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, false, true );
322363
}
323364

324-
$total_this_page = $totals[ $status ];
365+
// Narrow the current view by author when the "Filter by author" control is used.
366+
$plugin_author = isset( $_REQUEST['plugin_author'] ) ? sanitize_key( $_REQUEST['plugin_author'] ) : '';
367+
if ( '' !== $plugin_author ) {
368+
foreach ( $this->items as $plugin_file => $plugin_data ) {
369+
if ( $this->get_plugin_author_key( $plugin_file, $plugin_data ) !== $plugin_author ) {
370+
unset( $this->items[ $plugin_file ] );
371+
}
372+
}
373+
}
374+
375+
$total_this_page = ( '' !== $plugin_author ) ? count( $this->items ) : $totals[ $status ];
325376

326377
$js_plugins = array();
327378
foreach ( $plugins as $key => $list ) {
@@ -363,6 +414,34 @@ public function prepare_items() {
363414
);
364415
}
365416

417+
/**
418+
* Returns the author grouping key used to bucket a plugin in the "Filter by author" control.
419+
*
420+
* @since 7.1.0
421+
*
422+
* @param string $plugin_file Path to the plugin file relative to the plugins directory.
423+
* @param array $plugin_data An array of plugin data.
424+
* @return string The author grouping key, or an empty string when no author is set.
425+
*/
426+
protected function get_plugin_author_key( $plugin_file, $plugin_data ) {
427+
$author_name = isset( $plugin_data['AuthorName'] ) ? $plugin_data['AuthorName'] : '';
428+
$author_key = ( '' === $author_name ) ? '' : sanitize_title( $author_name );
429+
430+
/**
431+
* Filters the author grouping key for a plugin in the Plugins list "Filter by author" control.
432+
*
433+
* Returning the same key for several plugins groups them under a single author, which allows
434+
* author-name header variants (for example "Team Yoast" and "Yoast") to be merged.
435+
*
436+
* @since 7.1.0
437+
*
438+
* @param string $author_key Author grouping key derived from the plugin's AuthorName header.
439+
* @param string $plugin_file Path to the plugin file relative to the plugins directory.
440+
* @param array $plugin_data An array of plugin data.
441+
*/
442+
return apply_filters( 'plugins_list_plugin_author', $author_key, $plugin_file, $plugin_data );
443+
}
444+
366445
/**
367446
* @global string $s URL encoded search term.
368447
*
@@ -675,6 +754,11 @@ public function bulk_actions( $which = '' ) {
675754
protected function extra_tablenav( $which ) {
676755
global $status;
677756

757+
// Offer a single "Filter by author" control whenever more than one author is installed.
758+
if ( 'top' === $which && count( $this->plugin_authors ) > 1 ) {
759+
$this->author_filter();
760+
}
761+
678762
if ( ! in_array( $status, array( 'recently_activated', 'mustuse', 'dropins' ), true ) ) {
679763
return;
680764
}
@@ -699,6 +783,92 @@ protected function extra_tablenav( $which ) {
699783
echo '</div>';
700784
}
701785

786+
/**
787+
* Displays the "Filter by author" controls in the table navigation.
788+
*
789+
* The plugins list table is rendered inside the bulk-actions POST form, which
790+
* cannot contain a nested filter form. The native <select> and submit button are
791+
* therefore associated, via the HTML5 "form" attribute, with the separate GET form
792+
* printed by author_filter_form(). Filtering is a plain GET request: it needs no
793+
* JavaScript, and nothing is inlined for a Content Security Policy to block.
794+
*
795+
* @since 7.1.0
796+
*/
797+
protected function author_filter() {
798+
$current = isset( $_REQUEST['plugin_author'] ) ? sanitize_key( $_REQUEST['plugin_author'] ) : '';
799+
800+
echo '<div class="alignleft actions">';
801+
printf(
802+
'<label for="plugin-author-filter" class="screen-reader-text">%s</label>',
803+
esc_html__( 'Filter by author' )
804+
);
805+
echo '<select name="plugin_author" id="plugin-author-filter" form="plugin-author-filter-form">';
806+
printf(
807+
'<option value=""%s>%s</option>',
808+
selected( $current, '', false ),
809+
esc_html__( 'All authors' )
810+
);
811+
foreach ( $this->plugin_authors as $author_key => $author ) {
812+
$label = sprintf(
813+
/* translators: 1: Plugin author name. 2: Number of plugins by this author. */
814+
_x( '%1$s (%2$s)', 'plugins' ),
815+
$author['label'],
816+
number_format_i18n( $author['count'] )
817+
);
818+
printf(
819+
'<option value="%s"%s>%s</option>',
820+
esc_attr( $author_key ),
821+
selected( $current, $author_key, false ),
822+
esc_html( $label )
823+
);
824+
}
825+
echo '</select>';
826+
submit_button(
827+
__( 'Filter' ),
828+
'',
829+
'',
830+
false,
831+
array(
832+
'id' => 'plugin-author-filter-submit',
833+
'form' => 'plugin-author-filter-form',
834+
)
835+
);
836+
echo '</div>';
837+
}
838+
839+
/**
840+
* Prints the GET form that the "Filter by author" controls submit.
841+
*
842+
* This is printed outside the bulk-actions POST form (see wp-admin/plugins.php),
843+
* because a form cannot be nested in another form. The author <select> and its
844+
* submit button reference this form through their "form" attribute, so choosing an
845+
* author and pressing Filter performs a plain GET request. The hidden fields keep
846+
* the current status view and search term.
847+
*
848+
* @since 7.1.0
849+
*
850+
* @global string $status Current plugin status view.
851+
*/
852+
public function author_filter_form() {
853+
global $status;
854+
855+
if ( count( $this->plugin_authors ) < 2 ) {
856+
return;
857+
}
858+
859+
echo '<form id="plugin-author-filter-form" method="get">';
860+
if ( 'all' !== $status ) {
861+
printf( '<input type="hidden" name="plugin_status" value="%s" />', esc_attr( $status ) );
862+
}
863+
if ( isset( $_REQUEST['s'] ) && '' !== $_REQUEST['s'] ) {
864+
printf(
865+
'<input type="hidden" name="s" value="%s" />',
866+
esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) )
867+
);
868+
}
869+
echo '</form>';
870+
}
871+
702872
/**
703873
* @return string
704874
*/

src/wp-admin/plugins.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,8 @@
806806
<?php $wp_list_table->search_box( __( 'Search installed plugins' ), 'plugin' ); ?>
807807
</form>
808808

809+
<?php $wp_list_table->author_filter_form(); ?>
810+
809811
<form method="post" id="bulk-action-form">
810812

811813
<input type="hidden" name="plugin_status" value="<?php echo esc_attr( $status ); ?>" />

0 commit comments

Comments
 (0)