From 63a3b930b23199ceda02c6bb6bc4d0fb57203d6b Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Sun, 26 Apr 2026 18:04:35 -0300 Subject: [PATCH 1/8] Modules: rewrite legacy Backbone list-table as React + MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Modules admin page (`admin.php?page=jetpack_modules`) was the last classic Jetpack admin screen still rendered through the Backbone list-table view. This commit replaces that view with a React tree that mounts under a single `
`, dressed in the shared `jetpack-admin-page-layout` SCSS mixin and `` chrome — matching the rest of the admin-page-layout-unification series. Visual goal was parity with trunk: two-column layout (list left, filter sidebar right), preserved active-row highlight, preserved "Configure" link before each action, preserved offline-mode handling and unavailable_reason text. Modernization comes from primitive swaps: Activate / Deactivate buttons become a , segmented filters become a , and the top-right Dashboard / Settings links are passed through the AdminPage actions slot, replacing the legacy masthead buttons emitted by wrap_ui(). Plumbing -------- * class.jetpack-admin-page.php: render() now bypasses wrap_ui() for both page=jetpack and page=jetpack_modules, so the React tree is the only chrome on the page (no double masthead, no double footer). * class.jetpack-settings-page.php: page_render() collapses to a single root div plus the noscript / REST-disabled notices. * class.jetpack-modules-list-table.php: drops the three Backbone wp_register_script calls and enqueues the new modules-admin bundle plus the existing jetpackModulesData localized blob. * tools/webpack.config.js: adds modules-admin as a sibling entry to network-admin. * _inc/client/modules-admin/: new entry — index.tsx (React app) and style.scss (mixin scope + grid layout). Backbone sources (_inc/jetpack-modules.js, .models.js, .views.js) are left in place but no longer enqueued, so a single-commit revert restores the old experience. Deferred to follow-ups ---------------------- * Bulk activate / deactivate (dropdown + Apply, with row checkboxes). * Thickbox "More info" per-module modal (long_description / modalinfo). * URL state sync for search / filter / sort / tag via replaceState. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_inc/client/modules-admin/index.tsx | 407 ++++++++++++++++++ .../_inc/client/modules-admin/style.scss | 155 +++++++ .../admin-pages/class.jetpack-admin-page.php | 17 +- .../class.jetpack-settings-page.php | 103 +---- .../changelog/modules-page-react-rewrite | 4 + .../class.jetpack-modules-list-table.php | 98 ++--- .../plugins/jetpack/tools/webpack.config.js | 1 + 7 files changed, 636 insertions(+), 149 deletions(-) create mode 100644 projects/plugins/jetpack/_inc/client/modules-admin/index.tsx create mode 100644 projects/plugins/jetpack/_inc/client/modules-admin/style.scss create mode 100644 projects/plugins/jetpack/changelog/modules-page-react-rewrite diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx new file mode 100644 index 000000000000..ec896b81a9c1 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -0,0 +1,407 @@ +// Modules admin page (React entry point). +// +// Option C draft v1: replaces the legacy Backbone rendering of +// `admin.php?page=jetpack_modules` with a React tree wrapped in , +// styled via the shared `jetpack-admin-page-layout` mixin. +// +// Visual goal: preserve trunk's two-column layout (list left, filter sidebar +// right). Modernization comes from swapping primitives — Activate/Deactivate +// buttons become a ; segmented filters use ; +// the top-right Dashboard/Settings links are passed to 's `actions` +// slot. +// +// v1 scope: +// - Module list from the existing `jetpackModulesData` blob. +// - Search, active/inactive filter, sort (alphabetical / newest / popular). +// - Tag sub-filter with counts. +// - Per-row activate / deactivate via existing server URLs (nonce redirect — +// the page reloads with fresh data, no client-side state mutation needed). +// - Settings-link passthrough (server-rendered `configurable` anchor). +// - Dashboard / Settings as header actions on the right. +// +// Deferred (follow-ups): +// - Bulk activate / deactivate (dropdown + Apply, with row checkboxes). +// - Thickbox "More info" per-module modal. +// - URL state sync (replaceState for search/filter/tag/sort). +// +// The PHP layer (`Jetpack_Settings_Page::page_render`) emits a single +// `
` for this entry to mount into. + +import { AdminPage, ThemeProvider, getRedirectUrl } from '@automattic/jetpack-components'; +import { + Button, + SearchControl, + ToggleControl, + __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, // eslint-disable-line @wordpress/no-unsafe-wp-apis + __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis +} from '@wordpress/components'; +import { createRoot } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import clsx from 'clsx'; +import { useCallback, useMemo, useState } from 'react'; +import './style.scss'; + +type JetpackModule = { + module: string; + name: string; + description: string; + long_description?: string; + search_terms?: string; + sort?: number; + introduced?: string; + module_tags: string[]; + activated: boolean; + available: boolean; + disabled?: string; + configurable?: string; // Server-rendered HTML anchor, may be empty. + learn_more_button?: string; + activate_nonce?: string; + deactivate_nonce?: string; + unavailable_reason?: string; +}; + +declare global { + interface Window { + jetpackModulesData?: { + modules: Record< string, JetpackModule >; + i18n: { search_placeholder: string }; + modalinfo?: string | false; + nonces: { bulk: string }; + }; + } +} + +type FilterActive = 'all' | 'true' | 'false'; +type SortKey = 'name' | 'introduced' | 'sort'; + +const adminUrl = ( path: string ): string => { + // WordPress injects this global on all admin screens. + const base = + ( window as unknown as { ajaxurl?: string } ).ajaxurl?.replace( /admin-ajax\.php$/, '' ) || + '/wp-admin/'; + return `${ base }${ path }`; +}; + +/** + * Build the toggle URL for a module row using the existing server-side + * nonce. The server page handler at `admin.php?page=jetpack&action=...` + * redirects back to the referrer after completing the toggle, which is + * why we don't need an optimistic client-side update. + * + * @param {JetpackModule} item - Module row. + * @return {string | null} URL or null if unavailable. + */ +const toggleUrl = ( item: JetpackModule ): string | null => { + if ( ! item.available ) { + return null; + } + if ( item.activated && item.deactivate_nonce ) { + return adminUrl( + `admin.php?page=jetpack&action=deactivate&module=${ encodeURIComponent( + item.module + ) }&_wpnonce=${ encodeURIComponent( item.deactivate_nonce ) }` + ); + } + if ( ! item.activated && item.activate_nonce ) { + return adminUrl( + `admin.php?page=jetpack&action=activate&module=${ encodeURIComponent( + item.module + ) }&_wpnonce=${ encodeURIComponent( item.activate_nonce ) }` + ); + } + return null; +}; + +/** + * Module list row. + * + * @param {object} props - Props. + * @param {JetpackModule} props.item - Module data. + * @return {import('react').ReactNode} Row markup. + */ +function ModuleRow( { item }: { item: JetpackModule } ) { + const href = toggleUrl( item ); + const ariaLabel = item.activated + ? // translators: %s: module name. + __( 'Deactivate %s', 'jetpack' ).replace( '%s', item.name ) + : // translators: %s: module name. + __( 'Activate %s', 'jetpack' ).replace( '%s', item.name ); + + const onToggle = useCallback( () => { + if ( href ) { + window.location.href = href; + } + }, [ href ] ); + + return ( +
+
{ item.name }
+
+ { item.configurable && ( + Configure. Trusted output. + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={ { __html: item.configurable } } + /> + ) } + { ! item.available && item.unavailable_reason ? ( + { item.unavailable_reason } + ) : ( + + ) } +
+
+ ); +} + +/** + * Tag-list button. Pulls the tag click handler out so the button's + * `onClick` is a stable per-instance callback instead of a re-bound + * arrow function on every render of the parent. + * + * @param {object} props - Props. + * @param {string} props.tag - Tag value (empty string acts as "All"). + * @param {string} props.label - Display label. + * @param {number} props.count - Count to render after the label. + * @param {boolean} props.selected - Whether this tag is currently selected. + * @param {Function} props.onSelect - Called with the tag value when clicked. + * @return {import('react').ReactNode} Button. + */ +function TagButton( { + tag, + label, + count, + selected, + onSelect, +}: { + tag: string; + label: string; + count: number; + selected: boolean; + onSelect: ( tag: string ) => void; +} ) { + const onClick = useCallback( () => onSelect( tag ), [ onSelect, tag ] ); + return ( + + ); +} + +/** + * Main modules admin app. + * + * @return {import('react').ReactNode} App tree. + */ +function ModulesAdminApp() { + const data = window.jetpackModulesData; + const rawModules = useMemo( () => ( data ? Object.values( data.modules ) : [] ), [ data ] ); + + const [ search, setSearch ] = useState( '' ); + const [ filterActive, setFilterActive ] = useState< FilterActive >( 'all' ); + const [ sortBy, setSortBy ] = useState< SortKey >( 'name' ); + const [ moduleTag, setModuleTag ] = useState< string >( '' ); + + const tagCounts = useMemo( () => { + const counts: Record< string, number > = {}; + rawModules.forEach( m => + ( m.module_tags || [] ).forEach( t => { + counts[ t ] = ( counts[ t ] || 0 ) + 1; + } ) + ); + return counts; + }, [ rawModules ] ); + + const filtered = useMemo( () => { + let items = [ ...rawModules ]; + + if ( moduleTag ) { + items = items.filter( i => ( i.module_tags || [] ).includes( moduleTag ) ); + } + + if ( filterActive !== 'all' ) { + const want = filterActive === 'true'; + items = items.filter( i => !! i.activated === want ); + } + + if ( search ) { + const needle = search.toLowerCase(); + items = items.filter( i => + [ + i.name, + i.description, + i.long_description, + i.search_terms, + ( i.module_tags || [] ).join( ' ' ), + ] + .filter( Boolean ) + .join( ' ' ) + .toLowerCase() + .includes( needle ) + ); + } + + const dir = sortBy === 'introduced' ? -1 : 1; + items.sort( ( a, b ) => { + const av = ( a as unknown as Record< string, unknown > )[ sortBy ]; + const bv = ( b as unknown as Record< string, unknown > )[ sortBy ]; + if ( av === bv ) { + return 0; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ( av as any ) > ( bv as any ) ? dir : -dir; + } ); + + // Push unavailable modules to the bottom. + items.sort( ( a, b ) => Number( b.available ) - Number( a.available ) ); + + return items; + }, [ rawModules, moduleTag, filterActive, search, sortBy ] ); + + const sortedTags = useMemo( + () => Object.keys( tagCounts ).sort( ( a, b ) => a.localeCompare( b ) ), + [ tagCounts ] + ); + + const onChangeFilterActive = useCallback( ( v: string | number | undefined ) => { + setFilterActive( v as FilterActive ); + }, [] ); + + const onChangeSortBy = useCallback( ( v: string | number | undefined ) => { + setSortBy( v as SortKey ); + }, [] ); + + const headerActions = ( + + + + + ); + + if ( ! data ) { + return ( + +

{ __( 'No module data available.', 'jetpack' ) }

+
+ ); + } + + return ( + +
+
+
+ { filtered.length ? ( + filtered.map( item => ) + ) : ( +
+ { __( 'No modules found.', 'jetpack' ) } +
+ ) } +
+ + +
+
+
+ ); +} + +const container = document.getElementById( 'jp-modules-admin-root' ); +if ( container ) { + const root = createRoot( container ); + root.render( + + + + ); +} diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/style.scss b/projects/plugins/jetpack/_inc/client/modules-admin/style.scss new file mode 100644 index 000000000000..018a2fba63dd --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/modules-admin/style.scss @@ -0,0 +1,155 @@ +// Modules admin page styles. Scope the shared jetpack-admin-page-layout mixin +// to the wp-admin body class for `admin.php?page=jetpack_modules`. WP emits +// `admin_page_jetpack_modules` for this screen (the submenu is registered +// against an empty parent, so it resolves to `admin_page_*` — see +// class.jetpack-settings-page.php). + +@use "@automattic/jetpack-base-styles/admin-page-layout" as *; + +body.admin_page_jetpack_modules { + + @include jetpack-admin-page-layout; +} + +$jp-modules-admin-bg-active: #f6f7f7; +$jp-modules-admin-border: #dcdcde; +$jp-modules-admin-muted: #50575e; + +.jp-modules-admin { + padding: 24px; + + &__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 24px; + align-items: start; + + @media (max-width: 960px) { + grid-template-columns: 1fr; + } + } + + &__list { + background: #fff; + border: 1px solid $jp-modules-admin-border; + border-radius: 4px; + overflow: hidden; + } + + &__row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid $jp-modules-admin-border; + + &:last-child { + border-bottom: none; + } + + &.is-active { + background: $jp-modules-admin-bg-active; + } + + &.is-unavailable { + opacity: 0.7; + } + } + + &__row-name { + font-weight: 500; + } + + &__row-actions { + display: flex; + align-items: center; + gap: 12px; + + // Inherit the muted color for the server-rendered Configure link. + .jp-modules-admin__row-configure a { + color: $jp-modules-admin-muted; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__row-unavailable { + color: $jp-modules-admin-muted; + font-size: 12px; + font-style: italic; + } + + &__empty { + padding: 32px; + text-align: center; + color: $jp-modules-admin-muted; + } + + &__sidebar { + display: flex; + flex-direction: column; + gap: 24px; + position: sticky; + top: 16px; + } + + &__search { + // SearchControl ships its own borders; just ensure it stretches. + width: 100%; + } + + &__tags { + + .jp-modules-admin__tags-label { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $jp-modules-admin-muted; + margin-bottom: 8px; + } + + ul { + list-style: none; + margin: 0; + padding: 0; + } + + li { + margin: 0; + } + } + + &__tag { + appearance: none; + background: none; + border: none; + padding: 4px 0; + font: inherit; + color: var(--wp-admin-theme-color, #2271b1); + cursor: pointer; + text-align: left; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + + &.is-selected { + font-weight: 600; + color: inherit; + text-decoration: none; + } + } + + &__tag-count { + color: $jp-modules-admin-muted; + text-decoration: none; + font-weight: 400; + margin-left: 4px; + } +} diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php index 890647c1f0dd..4314c9d80cd8 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php @@ -106,20 +106,17 @@ public function render() { return; } - // Check if we are looking at the main dashboard. - if ( isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- View logic. + // Pages whose React tree provides its own header/footer chrome (via + // ``) skip the legacy `wrap_ui()` wrapper to avoid stacking + // two mastheads and two footers around the same content. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- View logic. + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; + if ( in_array( $page, array( 'jetpack', 'jetpack_modules' ), true ) ) { $this->page_render(); return; } - $args = array(); - - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( isset( $_GET['page'] ) && 'jetpack_modules' === $_GET['page'] ) { - $args['is-wide'] = true; - } - - self::wrap_ui( array( $this, 'page_render' ), $args ); + self::wrap_ui( array( $this, 'page_render' ), array() ); } /** diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php index 56fd7854f008..101f06d5b1d1 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php @@ -46,10 +46,22 @@ public function get_page_hook() { /** * Renders the module list table where you can use bulk action or row - * actions to activate/deactivate and configure modules + * actions to activate/deactivate and configure modules. + * + * Option C draft: this now renders a single `
` + * that the React bundle (`modules-admin.min.js`, enqueued via + * `Jetpack_Modules_List_Table::__construct`) mounts into. The legacy + * Backbone-driven markup below has been removed from the rendered output; + * the Backbone sources (`jetpack-modules.js`, `.models.js`, `.views.js`) are + * left in place but no longer enqueued, so a single-commit revert restores + * the old experience. + * + * @since $$next-version$$ */ public function page_render() { - $list_table = new Jetpack_Modules_List_Table(); + // Instantiate the list table purely for the side effect of registering + // and enqueuing the React bundle + `jetpackModulesData` localized blob. + new Jetpack_Modules_List_Table(); // We have static.html so let's continue trying to fetch the others. $noscript_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-noscript-notice.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file. @@ -82,92 +94,7 @@ public function page_render() { } echo $noscript_notice; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> - -
-
- -
-
- -
-
-
-
+ , using the shared jetpack-admin-page-layout mixin. Activate/Deactivate buttons become ; segmented filters use ; Dashboard/Settings move to the AdminPage actions slot. Backbone sources are unenqueued but left in place for a single-commit revert. diff --git a/projects/plugins/jetpack/class.jetpack-modules-list-table.php b/projects/plugins/jetpack/class.jetpack-modules-list-table.php index ee39f2e5115d..547ae1f5c1f9 100644 --- a/projects/plugins/jetpack/class.jetpack-modules-list-table.php +++ b/projects/plugins/jetpack/class.jetpack-modules-list-table.php @@ -5,8 +5,6 @@ * @package automattic/jetpack */ -use Automattic\Jetpack\Assets; - if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); } @@ -49,57 +47,55 @@ public function __construct() {

Jetpack::get_translated_modules( $this->all_items ), - 'i18n' => array( - 'search_placeholder' => __( 'Search modules…', 'jetpack' ), - ), - 'modalinfo' => $this->module_info_check( $modal_info, $this->all_items ), - 'nonces' => array( - 'bulk' => wp_create_nonce( 'bulk-jetpack_page_jetpack_modules' ), - ), - ) - ); + wp_enqueue_style( + 'jetpack-modules-admin', + plugins_url( '_inc/build/modules-admin.css', JETPACK__PLUGIN_FILE ), + array(), + $script_asset['version'] + ); - wp_enqueue_script( 'jetpack-modules-list-table' ); + wp_set_script_translations( 'jetpack-modules-admin', 'jetpack' ); + + wp_localize_script( + 'jetpack-modules-admin', + 'jetpackModulesData', + array( + 'modules' => Jetpack::get_translated_modules( $this->all_items ), + 'i18n' => array( + 'search_placeholder' => __( 'Search modules…', 'jetpack' ), + ), + 'modalinfo' => $this->module_info_check( $modal_info, $this->all_items ), + 'nonces' => array( + 'bulk' => wp_create_nonce( 'bulk-jetpack_page_jetpack_modules' ), + ), + ) + ); + } /** * Filters the js_templates callback value. diff --git a/projects/plugins/jetpack/tools/webpack.config.js b/projects/plugins/jetpack/tools/webpack.config.js index aa0e7cde5614..95ad27eb86cb 100644 --- a/projects/plugins/jetpack/tools/webpack.config.js +++ b/projects/plugins/jetpack/tools/webpack.config.js @@ -158,6 +158,7 @@ module.exports = [ }, 'plugins-page': path.join( __dirname, '../_inc/client', 'plugins-entry.js' ), 'network-admin': path.join( __dirname, '../_inc/client', 'network-admin.tsx' ), + 'modules-admin': path.join( __dirname, '../_inc/client', 'modules-admin', 'index.tsx' ), }, plugins: [ ...sharedWebpackConfig.plugins, From 5d4c5739795728dd8b991a8567ca0ef36ffbeb78 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Sun, 26 Apr 2026 18:29:08 -0300 Subject: [PATCH 2/8] Modules: fix CI failures (changelog type, ToggleControl label, Phan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changelog `Type: changed` is invalid for `plugins/jetpack` (the plugin's changelogger config only accepts major / enhancement / compat / bugfix / other). Switch to `enhancement`. * `` requires a `label` prop in its TS signature even though we render it visually unlabeled (the row name beside it acts as the label, and the per-row `aria-label` carries the accessibility name). Pass an empty string to satisfy the type. * `new Jetpack_Modules_List_Table()` is intentional fire-and-forget — the constructor enqueues the React bundle. Suppress Phan's PhanNoopNew with an inline annotation explaining the side effect. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- projects/plugins/jetpack/_inc/client/modules-admin/index.tsx | 1 + .../_inc/lib/admin-pages/class.jetpack-settings-page.php | 1 + projects/plugins/jetpack/changelog/modules-page-react-rewrite | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index ec896b81a9c1..886a8aec1cfd 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -158,6 +158,7 @@ function ModuleRow( { item }: { item: JetpackModule } ) { ) : ( , using the shared jetpack-admin-page-layout mixin. Activate/Deactivate buttons become ; segmented filters use ; Dashboard/Settings move to the AdminPage actions slot. Backbone sources are unenqueued but left in place for a single-commit revert. From 102691035e311ba7f2c1f0ee899616db021b5da4 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Sun, 26 Apr 2026 18:37:36 -0300 Subject: [PATCH 3/8] Modules: prefer @wordpress/ui for Button + Stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per project policy "use @wordpress/ui first, fall back to @wordpress/components only when ui doesn't ship the primitive": swap the two header-action primitives that have ui equivalents. * `Button` from @wordpress/components → `Button` from @wordpress/ui, with `nativeButton={ false }` + `render={ }` for navigational rendering (the same pattern Jetpack already uses elsewhere, e.g. AI MCP setup). * `__experimentalHStack` from @wordpress/components → `Stack` from @wordpress/ui with `direction="row" gap="sm"`. Loses one experimental import in the process. ui doesn't ship `ToggleControl`, `ToggleGroupControl`, or `SearchControl` yet, so those stay imported from @wordpress/components. `Page` (the AdminPage chrome) is already pulled from @wordpress/admin-ui transitively via @automattic/jetpack-components' AdminPage component. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_inc/client/modules-admin/index.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index 886a8aec1cfd..6c830514312d 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -29,15 +29,14 @@ import { AdminPage, ThemeProvider, getRedirectUrl } from '@automattic/jetpack-components'; import { - Button, SearchControl, ToggleControl, __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalToggleGroupControlOption as ToggleGroupControlOption, // eslint-disable-line @wordpress/no-unsafe-wp-apis - __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; import { createRoot } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Button, Stack } from '@wordpress/ui'; import clsx from 'clsx'; import { useCallback, useMemo, useState } from 'react'; import './style.scss'; @@ -292,14 +291,24 @@ function ModulesAdminApp() { }, [] ); const headerActions = ( - - - - + ); if ( ! data ) { From b5e3054e998f4232857df0b23e97fb4d4e04151f Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Sun, 26 Apr 2026 19:12:08 -0300 Subject: [PATCH 4/8] Modules: rewrite session-context comments as code-context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original commit's comments referenced PR-history phrasing like "Option C draft v1", "v1 scope", and "deferred follow-ups" — context that belongs in the PR description, not in code that future readers will see in isolation. Rewrite the file/method header comments and inline annotations to describe what the code does and why it exists, without referring to the rewrite itself or the migration milestone. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_inc/client/modules-admin/index.tsx | 36 +++++-------------- .../class.jetpack-settings-page.php | 20 +++++------ .../class.jetpack-modules-list-table.php | 13 +++---- 3 files changed, 22 insertions(+), 47 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index 6c830514312d..cc4987689796 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -1,31 +1,13 @@ -// Modules admin page (React entry point). +// Modules admin page React entry. Mounts a list view (search, active/inactive +// filter, sort, tag sub-filter, per-row activate/deactivate toggle) into the +// `
` emitted by `Jetpack_Settings_Page`, +// dressed in `` chrome and the shared `jetpack-admin-page-layout` +// SCSS mixin. // -// Option C draft v1: replaces the legacy Backbone rendering of -// `admin.php?page=jetpack_modules` with a React tree wrapped in , -// styled via the shared `jetpack-admin-page-layout` mixin. -// -// Visual goal: preserve trunk's two-column layout (list left, filter sidebar -// right). Modernization comes from swapping primitives — Activate/Deactivate -// buttons become a ; segmented filters use ; -// the top-right Dashboard/Settings links are passed to 's `actions` -// slot. -// -// v1 scope: -// - Module list from the existing `jetpackModulesData` blob. -// - Search, active/inactive filter, sort (alphabetical / newest / popular). -// - Tag sub-filter with counts. -// - Per-row activate / deactivate via existing server URLs (nonce redirect — -// the page reloads with fresh data, no client-side state mutation needed). -// - Settings-link passthrough (server-rendered `configurable` anchor). -// - Dashboard / Settings as header actions on the right. -// -// Deferred (follow-ups): -// - Bulk activate / deactivate (dropdown + Apply, with row checkboxes). -// - Thickbox "More info" per-module modal. -// - URL state sync (replaceState for search/filter/tag/sort). -// -// The PHP layer (`Jetpack_Settings_Page::page_render`) emits a single -// `
` for this entry to mount into. +// Per-row toggle navigates to the existing +// `admin.php?page=jetpack&action=activate|deactivate` server URLs. The server +// redirects back and the page reloads with fresh module state, so no +// client-side state mutation or REST roundtrip is needed. import { AdminPage, ThemeProvider, getRedirectUrl } from '@automattic/jetpack-components'; import { diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php index f184dd9cb0c7..fcaea87b9412 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php @@ -45,22 +45,20 @@ public function get_page_hook() { } /** - * Renders the module list table where you can use bulk action or row - * actions to activate/deactivate and configure modules. + * Render the page body. * - * Option C draft: this now renders a single `
` - * that the React bundle (`modules-admin.min.js`, enqueued via - * `Jetpack_Modules_List_Table::__construct`) mounts into. The legacy - * Backbone-driven markup below has been removed from the rendered output; - * the Backbone sources (`jetpack-modules.js`, `.models.js`, `.views.js`) are - * left in place but no longer enqueued, so a single-commit revert restores - * the old experience. + * Emits a single mount point (`
`) for the + * React `modules-admin` bundle, plus the noscript and REST-disabled + * fallback notices. `Jetpack_Modules_List_Table`'s constructor enqueues + * the bundle and localizes the `jetpackModulesData` blob the React app + * reads on mount. * * @since $$next-version$$ */ public function page_render() { - // Instantiate the list table purely for the side effect of registering - // and enqueuing the React bundle + `jetpackModulesData` localized blob. + // `Jetpack_Modules_List_Table::__construct` enqueues the React bundle + // and localizes `jetpackModulesData`, so instantiate it for the side + // effect. // @phan-suppress-next-line PhanNoopNew -- Constructor enqueues scripts. new Jetpack_Modules_List_Table(); diff --git a/projects/plugins/jetpack/class.jetpack-modules-list-table.php b/projects/plugins/jetpack/class.jetpack-modules-list-table.php index 547ae1f5c1f9..0e894a3395ac 100644 --- a/projects/plugins/jetpack/class.jetpack-modules-list-table.php +++ b/projects/plugins/jetpack/class.jetpack-modules-list-table.php @@ -47,15 +47,10 @@ public function __construct() {

Date: Mon, 27 Apr 2026 01:59:57 -0300 Subject: [PATCH 5/8] Modules: persist filter state in URL via history.replaceState Search, view, sort, and tag filters now seed from `URLSearchParams` on mount and reflect into the URL on every change. Defaults are dropped so a clean URL stays clean. Refreshing or sharing the URL restores all four filters; visual is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_inc/client/modules-admin/index.tsx | 92 ++++++++++++++++++- .../changelog/modules-page-url-state-sync | 4 + 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/modules-page-url-state-sync diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index cc4987689796..fc0f4fdc4f77 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -20,7 +20,7 @@ import { createRoot } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Stack } from '@wordpress/ui'; import clsx from 'clsx'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import './style.scss'; type JetpackModule = { @@ -56,6 +56,82 @@ declare global { type FilterActive = 'all' | 'true' | 'false'; type SortKey = 'name' | 'introduced' | 'sort'; +const FILTER_VALUES: readonly FilterActive[] = [ 'all', 'true', 'false' ] as const; +const SORT_VALUES: readonly SortKey[] = [ 'name', 'introduced', 'sort' ] as const; + +type FilterState = { + search: string; + filterActive: FilterActive; + sortBy: SortKey; + moduleTag: string; +}; + +const DEFAULT_FILTER_STATE: FilterState = { + search: '', + filterActive: 'all', + sortBy: 'name', + moduleTag: '', +}; + +/** + * Read the four filter values from the current URL. Unknown values fall + * back to defaults so a hand-typed or stale URL never wedges the page. + * + * @return {FilterState} Seed state for the four filter inputs. + */ +const readFilterStateFromUrl = (): FilterState => { + if ( typeof window === 'undefined' ) { + return DEFAULT_FILTER_STATE; + } + const params = new URLSearchParams( window.location.search ); + const filterParam = params.get( 'filter' ); + const sortParam = params.get( 'sort' ); + return { + search: params.get( 'search' ) || '', + filterActive: FILTER_VALUES.includes( filterParam as FilterActive ) + ? ( filterParam as FilterActive ) + : 'all', + sortBy: SORT_VALUES.includes( sortParam as SortKey ) ? ( sortParam as SortKey ) : 'name', + moduleTag: params.get( 'tag' ) || '', + }; +}; + +/** + * Reflect the four filter values into the URL via `history.replaceState`. + * Defaults (e.g. empty search, `filter=all`) are dropped so a clean URL + * stays clean. Only our four params are touched — `page=jetpack_modules` + * and anything else WP appends is preserved. + * + * @param {FilterState} state - Current filter values. + */ +const writeFilterStateToUrl = ( state: FilterState ): void => { + if ( typeof window === 'undefined' ) { + return; + } + const params = new URLSearchParams( window.location.search ); + const set = ( key: string, value: string, isDefault: boolean ) => { + if ( isDefault ) { + params.delete( key ); + } else { + params.set( key, value ); + } + }; + set( 'search', state.search, state.search === '' ); + set( 'filter', state.filterActive, state.filterActive === 'all' ); + set( 'sort', state.sortBy, state.sortBy === 'name' ); + set( 'tag', state.moduleTag, state.moduleTag === '' ); + + const query = params.toString(); + const next = `${ window.location.pathname }${ query ? `?${ query }` : '' }${ + window.location.hash + }`; + if ( + next !== `${ window.location.pathname }${ window.location.search }${ window.location.hash }` + ) { + window.history.replaceState( window.history.state, '', next ); + } +}; + const adminUrl = ( path: string ): string => { // WordPress injects this global on all admin screens. const base = @@ -198,10 +274,16 @@ function ModulesAdminApp() { const data = window.jetpackModulesData; const rawModules = useMemo( () => ( data ? Object.values( data.modules ) : [] ), [ data ] ); - const [ search, setSearch ] = useState( '' ); - const [ filterActive, setFilterActive ] = useState< FilterActive >( 'all' ); - const [ sortBy, setSortBy ] = useState< SortKey >( 'name' ); - const [ moduleTag, setModuleTag ] = useState< string >( '' ); + // Seed once from the URL so refresh / shared links restore filter state. + const initialState = useMemo( readFilterStateFromUrl, [] ); + const [ search, setSearch ] = useState( initialState.search ); + const [ filterActive, setFilterActive ] = useState< FilterActive >( initialState.filterActive ); + const [ sortBy, setSortBy ] = useState< SortKey >( initialState.sortBy ); + const [ moduleTag, setModuleTag ] = useState< string >( initialState.moduleTag ); + + useEffect( () => { + writeFilterStateToUrl( { search, filterActive, sortBy, moduleTag } ); + }, [ search, filterActive, sortBy, moduleTag ] ); const tagCounts = useMemo( () => { const counts: Record< string, number > = {}; diff --git a/projects/plugins/jetpack/changelog/modules-page-url-state-sync b/projects/plugins/jetpack/changelog/modules-page-url-state-sync new file mode 100644 index 000000000000..8a8a29e10667 --- /dev/null +++ b/projects/plugins/jetpack/changelog/modules-page-url-state-sync @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Modules admin page: persist search/filter/sort/tag in the URL via history.replaceState so a refresh or shared link restores filter state. Defaults are dropped from the URL to keep clean URLs clean. From 7fcd75fc127fe8082d13bc9259dcc39e848e28b8 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Mon, 27 Apr 2026 11:10:22 -0300 Subject: [PATCH 6/8] Modules: link module name to learn_more_button (faithful to trunk) Wraps each module name in an anchor pointing at item.learn_more_button (the module's docs / product / wp-admin URL), opening in a new tab. This matches trunk's Backbone js_template behavior. Modules without a learn_more_button keep the static name label. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jetpack/_inc/client/modules-admin/index.tsx | 13 ++++++++++++- .../jetpack/_inc/client/modules-admin/style.scss | 8 ++++++++ .../jetpack/changelog/modules-page-name-link | 4 ++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/jetpack/changelog/modules-page-name-link diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index fc0f4fdc4f77..e930f448beb5 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -199,7 +199,18 @@ function ModuleRow( { item }: { item: JetpackModule } ) { 'is-unavailable': ! item.available, } ) } > -
{ item.name }
+ { item.learn_more_button ? ( +
+ { item.name } + + ) : ( +
{ item.name }
+ ) }
{ item.configurable && ( Date: Mon, 27 Apr 2026 11:29:26 -0300 Subject: [PATCH 7/8] Modules: restore bulk activate / deactivate Adds a per-row checkbox column, a master "select all" checkbox scoped to the currently filtered + available rows (with indeterminate when partial), and a Bulk actions select + Apply button above the list. Submitting routes to the existing admin.php?page=jetpack&action=bulk-(activate|deactivate) handler with nonces.bulk; the server validates the nonce, runs activate/deactivate per slug, and redirects back so module state refreshes naturally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_inc/client/modules-admin/index.tsx | 218 +++++++++++++++++- .../_inc/client/modules-admin/style.scss | 45 +++- .../changelog/modules-page-bulk-actions | 4 + 3 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/modules-page-bulk-actions diff --git a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx index e930f448beb5..5f53938f861c 100644 --- a/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -12,6 +12,7 @@ import { AdminPage, ThemeProvider, getRedirectUrl } from '@automattic/jetpack-components'; import { SearchControl, + SelectControl, ToggleControl, __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalToggleGroupControlOption as ToggleGroupControlOption, // eslint-disable-line @wordpress/no-unsafe-wp-apis @@ -20,7 +21,7 @@ import { createRoot } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Stack } from '@wordpress/ui'; import clsx from 'clsx'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './style.scss'; type JetpackModule = { @@ -55,6 +56,7 @@ declare global { type FilterActive = 'all' | 'true' | 'false'; type SortKey = 'name' | 'introduced' | 'sort'; +type BulkAction = '' | 'bulk-activate' | 'bulk-deactivate'; const FILTER_VALUES: readonly FilterActive[] = [ 'all', 'true', 'false' ] as const; const SORT_VALUES: readonly SortKey[] = [ 'name', 'introduced', 'sort' ] as const; @@ -173,11 +175,21 @@ const toggleUrl = ( item: JetpackModule ): string | null => { /** * Module list row. * - * @param {object} props - Props. - * @param {JetpackModule} props.item - Module data. + * @param {object} props - Props. + * @param {JetpackModule} props.item - Module data. + * @param {boolean} props.selected - Whether this row is checked for bulk action. + * @param {Function} props.onSelect - Called with (slug, checked) when the row checkbox toggles. * @return {import('react').ReactNode} Row markup. */ -function ModuleRow( { item }: { item: JetpackModule } ) { +function ModuleRow( { + item, + selected, + onSelect, +}: { + item: JetpackModule; + selected: boolean; + onSelect: ( slug: string, checked: boolean ) => void; +} ) { const href = toggleUrl( item ); const ariaLabel = item.activated ? // translators: %s: module name. @@ -191,6 +203,11 @@ function ModuleRow( { item }: { item: JetpackModule } ) { } }, [ href ] ); + const onSelectChange = useCallback( + ( e: React.ChangeEvent< HTMLInputElement > ) => onSelect( item.module, e.target.checked ), + [ onSelect, item.module ] + ); + return (
+
+ { item.available && ( + + ) } +
{ item.learn_more_button ? ( void; + bulkAction: BulkAction; + onChangeBulkAction: ( value: BulkAction ) => void; + selectedCount: number; + onApply: () => void; +} ) { + const masterRef = useRef< HTMLInputElement | null >( null ); + useEffect( () => { + if ( masterRef.current ) { + masterRef.current.indeterminate = ! allSelected && someSelected; + } + }, [ allSelected, someSelected ] ); + + const onMasterChange = useCallback( + ( e: React.ChangeEvent< HTMLInputElement > ) => onToggleSelectAll( e.target.checked ), + [ onToggleSelectAll ] + ); + + const onSelectChange = useCallback( + ( v: string ) => onChangeBulkAction( v as BulkAction ), + [ onChangeBulkAction ] + ); + + const canApply = bulkAction !== '' && selectedCount > 0; + + return ( +
+ + + + { selectedCount > 0 && ( + + { + // translators: %d: number of modules selected for bulk action. + __( '%d selected', 'jetpack' ).replace( '%d', String( selectedCount ) ) + } + + ) } +
+ ); +} + /** * Main modules admin app. * @@ -365,6 +487,60 @@ function ModulesAdminApp() { setSortBy( v as SortKey ); }, [] ); + // Bulk-action selection. Selection is a `Set` that persists across + // filter changes — modules that are selected but no longer visible still + // submit on Apply. This is intentional: it matches the legacy form + // behavior and keeps the implementation simple. + const [ selected, setSelected ] = useState< Set< string > >( () => new Set() ); + const [ bulkAction, setBulkAction ] = useState< BulkAction >( '' ); + + const onChangeBulkAction = useCallback( ( v: BulkAction ) => setBulkAction( v ), [] ); + + const onSelectRow = useCallback( ( slug: string, checked: boolean ) => { + setSelected( prev => { + const next = new Set( prev ); + if ( checked ) { + next.add( slug ); + } else { + next.delete( slug ); + } + return next; + } ); + }, [] ); + + // "Available + currently visible" rows are what the master checkbox toggles. + const filteredAvailable = useMemo( () => filtered.filter( i => i.available ), [ filtered ] ); + const allSelected = + filteredAvailable.length > 0 && filteredAvailable.every( i => selected.has( i.module ) ); + const someSelected = filteredAvailable.some( i => selected.has( i.module ) ); + + const onToggleSelectAll = useCallback( + ( checked: boolean ) => { + setSelected( prev => { + const next = new Set( prev ); + if ( checked ) { + filteredAvailable.forEach( i => next.add( i.module ) ); + } else { + filteredAvailable.forEach( i => next.delete( i.module ) ); + } + return next; + } ); + }, + [ filteredAvailable ] + ); + + const onApplyBulk = useCallback( () => { + if ( ! bulkAction || selected.size === 0 || ! data ) { + return; + } + const params = new URLSearchParams(); + params.set( 'page', 'jetpack' ); + params.set( 'action', bulkAction ); + selected.forEach( slug => params.append( 'modules[]', slug ) ); + params.set( '_wpnonce', data.nonces.bulk ); + window.location.href = adminUrl( `admin.php?${ params.toString() }` ); + }, [ bulkAction, selected, data ] ); + const headerActions = ( { selectedCount > 0 && ( @@ -493,6 +501,7 @@ function ModulesAdminApp() { // behavior and keeps the implementation simple. const [ selected, setSelected ] = useState< Set< string > >( () => new Set() ); const [ bulkAction, setBulkAction ] = useState< BulkAction >( '' ); + const [ applying, setApplying ] = useState( false ); const onChangeBulkAction = useCallback( ( v: BulkAction ) => setBulkAction( v ), [] ); @@ -530,7 +539,7 @@ function ModulesAdminApp() { ); const onApplyBulk = useCallback( () => { - if ( ! bulkAction || selected.size === 0 || ! data ) { + if ( ! bulkAction || selected.size === 0 || ! data || applying ) { return; } const params = new URLSearchParams(); @@ -538,8 +547,15 @@ function ModulesAdminApp() { params.set( 'action', bulkAction ); selected.forEach( slug => params.append( 'modules[]', slug ) ); params.set( '_wpnonce', data.nonces.bulk ); - window.location.href = adminUrl( `admin.php?${ params.toString() }` ); - }, [ bulkAction, selected, data ] ); + const url = adminUrl( `admin.php?${ params.toString() }` ); + + // Flip to loading first so React can paint the spinner before the + // page navigates away. setTimeout(0) yields one frame to the browser. + setApplying( true ); + setTimeout( () => { + window.location.href = url; + }, 0 ); + }, [ bulkAction, selected, data, applying ] ); const headerActions = ( @@ -589,6 +605,7 @@ function ModulesAdminApp() { onChangeBulkAction={ onChangeBulkAction } selectedCount={ selected.size } onApply={ onApplyBulk } + applying={ applying } />
{ filtered.length ? (