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..53f90889f0e2 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/modules-admin/index.tsx @@ -0,0 +1,703 @@ +// 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. +// +// 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 { + 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 +} from '@wordpress/components'; +import { createRoot } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Button, Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +import { useCallback, useEffect, useMemo, useRef, 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'; +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; + +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 = + ( 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. + * @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, + selected, + onSelect, +}: { + item: JetpackModule; + selected: boolean; + onSelect: ( slug: string, checked: boolean ) => void; +} ) { + 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 ] ); + + const onSelectChange = useCallback( + ( e: React.ChangeEvent< HTMLInputElement > ) => onSelect( item.module, e.target.checked ), + [ onSelect, item.module ] + ); + + return ( +
+
+ { item.available && ( + + ) } +
+ { item.learn_more_button ? ( + + { item.name } + + ) : ( +
{ 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 ( + + ); +} + +/** + * Bulk-actions toolbar shown above the module list. Renders a master + * "select all" checkbox (scoped to the currently filtered + available + * rows), a Bulk actions select, and an Apply button. Submitting routes + * to the existing `admin.php?page=jetpack&action=bulk-…` URL — the + * server validates the nonce, runs activate/deactivate per slug, and + * redirects back so module state refreshes naturally. + * + * @param {object} props - Props. + * @param {boolean} props.allSelected - All filtered+available rows are checked. + * @param {boolean} props.someSelected - At least one filtered+available row is checked. + * @param {Function} props.onToggleSelectAll - Called with the desired master state. + * @param {string} props.bulkAction - Currently chosen bulk action ('' = none). + * @param {Function} props.onChangeBulkAction - Called with the new bulk action value. + * @param {number} props.selectedCount - How many modules are currently selected (across all filters). + * @param {Function} props.onApply - Called when the Apply button is pressed. + * @param {boolean} props.applying - Whether a bulk submission is in flight (toggles loading + disabled). + * @return {import('react').ReactNode} Toolbar markup. + */ +function BulkActionsToolbar( { + allSelected, + someSelected, + onToggleSelectAll, + bulkAction, + onChangeBulkAction, + selectedCount, + onApply, + applying, +}: { + allSelected: boolean; + someSelected: boolean; + onToggleSelectAll: ( checked: boolean ) => void; + bulkAction: BulkAction; + onChangeBulkAction: ( value: BulkAction ) => void; + selectedCount: number; + onApply: () => void; + applying: boolean; +} ) { + 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. + * + * @return {import('react').ReactNode} App tree. + */ +function ModulesAdminApp() { + const data = window.jetpackModulesData; + const rawModules = useMemo( () => ( data ? Object.values( data.modules ) : [] ), [ data ] ); + + // 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 > = {}; + 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 ); + }, [] ); + + // 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 [ applying, setApplying ] = useState( false ); + + 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 || applying ) { + 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 ); + 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 = ( + + + + + ); + + 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..478614e83a8c --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/modules-admin/style.scss @@ -0,0 +1,206 @@ +// 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; + } + } + + &__main { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__bulk-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: #fff; + border: 1px solid $jp-modules-admin-border; + border-radius: 4px; + } + + &__bulk-master { + flex: 0 0 auto; + margin: 0 4px; + } + + &__bulk-select { + flex: 0 0 auto; + min-width: 160px; + + // Suppress the SelectControl margin-bottom default. + .components-base-control__field { + margin-bottom: 0; + } + } + + &__bulk-count { + color: $jp-modules-admin-muted; + font-size: 12px; + margin-left: auto; + } + + &__list { + background: #fff; + border: 1px solid $jp-modules-admin-border; + border-radius: 4px; + overflow: hidden; + } + + &__row { + display: grid; + grid-template-columns: auto 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-select { + + // Reserve checkbox-width even for unavailable rows so names line up. + min-width: 16px; + } + + &__row-name { + font-weight: 500; + color: inherit; + text-decoration: none; + + &:hover, + &:focus-visible { + color: var(--wp-admin-theme-color, #2271b1); + text-decoration: underline; + } + } + + &__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..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,11 +45,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 + * Render the page body. + * + * 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() { - $list_table = new Jetpack_Modules_List_Table(); + // `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(); // 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 +93,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/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. diff --git a/projects/plugins/jetpack/class.jetpack-modules-list-table.php b/projects/plugins/jetpack/class.jetpack-modules-list-table.php index ee39f2e5115d..0e894a3395ac 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,50 @@ 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,