From 61708700e500da09b7e7f22d1ad005eb02cd97a7 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 22 Sep 2026 12:42:44 -0500 Subject: [PATCH 1/2] My Jetpack: show the forced note on plugins a host enables or disables Read a plugin forced through option_active_plugins, or network-activated, the way Jetpack_Modules_Overrides reads modules, and give its Features grid card and modal the same treatment as a forced module. Co-Authored-By: Claude Opus 5 (1M context) --- .../_inc/components/modules-list/utils.ts | 26 +++--- .../features/feature-action.tsx | 4 + .../features/feature-modal-actions.tsx | 2 +- .../features/feature-state.ts | 24 +++-- .../features/test/feature-controls.test.tsx | 50 ++++++++++ .../features/test/feature-state.test.ts | 17 ++++ .../jetpack-2382-forced-plugin-toggle | 4 + projects/packages/my-jetpack/global.d.ts | 2 + .../my-jetpack/src/class-main-features.php | 41 +++++++++ .../Main_Features_Plugin_Override_Test.php | 91 +++++++++++++++++++ 10 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 projects/packages/my-jetpack/changelog/jetpack-2382-forced-plugin-toggle create mode 100644 projects/packages/my-jetpack/tests/php/Main_Features_Plugin_Override_Test.php diff --git a/projects/packages/my-jetpack/_inc/components/modules-list/utils.ts b/projects/packages/my-jetpack/_inc/components/modules-list/utils.ts index 1d3862b7b8ef..af8c3822c2c3 100644 --- a/projects/packages/my-jetpack/_inc/components/modules-list/utils.ts +++ b/projects/packages/my-jetpack/_inc/components/modules-list/utils.ts @@ -4,6 +4,18 @@ import { MyJetpackModule } from '../../types'; export const JETPACK_MODULES_NOT_FOR_MULTISITE = [ 'waf', 'wordads' ]; +/** + * Why a feature forced on or off by the host has no switch. + * + * @param {string} override - 'active' when forced on, 'inactive' when forced off. + * @return The note shown in place of the switch. + */ +export function getOverrideReason( override: 'active' | 'inactive' ) { + return override === 'active' + ? __( 'Enabled by your host or site administrator', 'jetpack-my-jetpack' ) + : __( 'Disabled by your host or site administrator', 'jetpack-my-jetpack' ); +} + /** * Check whether the site owner can toggle a module, and why not when they can't. * @@ -13,18 +25,8 @@ export const JETPACK_MODULES_NOT_FOR_MULTISITE = [ 'waf', 'wordads' ]; */ export function getModuleStatus( $module: MyJetpackModule ) { // A toggle for a module forced through `jetpack_active_modules` would only flip back. - if ( $module.override === 'active' ) { - return { - isAvailable: false, - reason: __( 'Enabled by your host or site administrator', 'jetpack-my-jetpack' ), - }; - } - - if ( $module.override === 'inactive' ) { - return { - isAvailable: false, - reason: __( 'Disabled by your host or site administrator', 'jetpack-my-jetpack' ), - }; + if ( $module.override === 'active' || $module.override === 'inactive' ) { + return { isAvailable: false, reason: getOverrideReason( $module.override ) }; } // If the module is not supported on multisite, we set the availability to false and provide a reason. diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-action.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-action.tsx index 861d0bc41a8a..3ea43d4e3af2 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-action.tsx +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-action.tsx @@ -112,6 +112,10 @@ export function FeatureAction( { state, describedby }: FeatureActionProps ) { ); case 'plugin': + if ( control.override ) { + return null; + } + return ( ) : null } - { control.kind === 'plugin' ? ( + { control.kind === 'plugin' && ! forcedReason ? ( ) : null } diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-state.ts b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-state.ts index 3d3d5b67b225..f651fadad7c5 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-state.ts +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-state.ts @@ -6,7 +6,7 @@ import { pluginSwitchKey, useRequestedSwitches, } from '../../../data/requested-switch-state'; -import { getModuleStatus } from '../../modules-list/utils'; +import { getModuleStatus, getOverrideReason } from '../../modules-list/utils'; import { getProductModules } from '../products/mappings'; import { useAllJetpackModules } from '../products/use-all-jetpack-modules'; import type { ProductCamelCase } from '../../../data/types'; @@ -20,7 +20,7 @@ import type { JetpackModuleSlug, MyJetpackModule } from '../../../types'; */ export type FeatureControl = | { kind: 'module'; module: MyJetpackModule } - | { kind: 'plugin'; plugin: string } + | { kind: 'plugin'; plugin: string; override?: 'active' | 'inactive' } | { kind: 'install-plugin'; plugin: string } | { kind: 'install-jetpack'; installed: boolean } | { kind: 'none' }; @@ -41,7 +41,7 @@ export type FeatureState = { }; /** - * Why a feature can't be switched here, when a host forced its module on or off. + * Why a feature can't be switched here, when a host forced its module or plugin on or off. * * @param state - The feature's live state. * @return The reason, or null when nothing forced it. @@ -49,9 +49,15 @@ export type FeatureState = { export function getForcedReason( state: FeatureState ): string | null { const { control } = state; - return control.kind === 'module' && control.module.override - ? ( getModuleStatus( control.module ).reason ?? null ) - : null; + if ( control.kind === 'module' && control.module.override ) { + return getModuleStatus( control.module ).reason ?? null; + } + + if ( control.kind === 'plugin' && control.override ) { + return getOverrideReason( control.override ); + } + + return null; } /** @@ -118,7 +124,11 @@ export function resolveFeatureState( feature, product, status: feature.plugin_status === 'active' || moduleIsOn ? 'active' : 'inactive', - control: { kind: 'plugin', plugin: feature.plugin }, + control: { + kind: 'plugin', + plugin: feature.plugin, + override: feature.plugin_override || undefined, + }, }; } diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-controls.test.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-controls.test.tsx index d19a2e1c7ac3..f8d3c37e63a3 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-controls.test.tsx +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-controls.test.tsx @@ -142,6 +142,19 @@ describe( 'FeatureAction', () => { expect( container ).toBeEmptyDOMElement(); } ); + it( 'leaves the switch slot empty for a plugin a host forced on', () => { + const { container } = render( + + ); + + expect( container ).toBeEmptyDOMElement(); + } ); + it( 'renders nothing when this site cannot switch the feature', () => { const { container } = render( ); @@ -212,6 +225,30 @@ describe( 'FeatureModalActions', () => { expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument(); } ); + it( 'shows the note instead of Deactivate for a plugin a host forced on', () => { + render( + + ); + + expect( screen.getByText( 'Enabled by your host or site administrator' ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument(); + } ); + + it( 'offers no Activate for a plugin a host forced off', () => { + render( + + ); + + expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument(); + } ); + it( 'leaves the reason for a module forced off to "How to get it"', () => { const forcedOff = { ...forcedModule, @@ -242,4 +279,17 @@ describe( 'FeatureItem', () => { expect( screen.queryByText( /by your host or site administrator/ ) ).not.toBeInTheDocument(); expect( screen.queryByRole( 'checkbox' ) ).not.toBeInTheDocument(); } ); + + it( 'shows only the status for a plugin a host forced off: no switch, no note', () => { + render( + + ); + + expect( screen.getByText( 'Inactive' ) ).toBeInTheDocument(); + expect( screen.queryByText( /by your host or site administrator/ ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'checkbox' ) ).not.toBeInTheDocument(); + } ); } ); diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-state.test.ts b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-state.test.ts index a33a53d3754e..644f36bf0faf 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-state.test.ts +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/test/feature-state.test.ts @@ -67,6 +67,23 @@ describe( 'resolveFeatureState', () => { expect( state.status ).toBe( 'active' ); } ); + it( 'carries a host override on an installed standalone plugin', () => { + const state = resolve( + buildFeature( { + plugin: 'jetpack-boost', + plugin_status: 'inactive', + plugin_override: 'inactive', + } ), + 'active' + ); + + expect( state.control ).toEqual( { + kind: 'plugin', + plugin: 'jetpack-boost', + override: 'inactive', + } ); + } ); + it( 'offers nothing when Jetpack ships the feature but its module is unavailable', () => { const state = resolve( buildFeature( { in_jetpack: true, module: 'stats' } ), 'active', { stats: buildModule( { available: false } ), diff --git a/projects/packages/my-jetpack/changelog/jetpack-2382-forced-plugin-toggle b/projects/packages/my-jetpack/changelog/jetpack-2382-forced-plugin-toggle new file mode 100644 index 000000000000..130ab75ef309 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/jetpack-2382-forced-plugin-toggle @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Features: Show a note instead of a switch on a plugin your host or site administrator has enabled or disabled. diff --git a/projects/packages/my-jetpack/global.d.ts b/projects/packages/my-jetpack/global.d.ts index 6ba0e55b064d..a2b6c8c18b43 100644 --- a/projects/packages/my-jetpack/global.d.ts +++ b/projects/packages/my-jetpack/global.d.ts @@ -482,6 +482,8 @@ type MainFeature = { plugin_name: string; plugin_url: string; plugin_status: MainFeaturePluginStatus; + // Set when the host forces the plugin, so the owner's switch would only flip back. + plugin_override: '' | 'active' | 'inactive'; paid_highlights: string[]; plans: Array< { slug: string; name: string } >; paid_product: string; diff --git a/projects/packages/my-jetpack/src/class-main-features.php b/projects/packages/my-jetpack/src/class-main-features.php index 43889aba7ed3..5d5b93261d3f 100644 --- a/projects/packages/my-jetpack/src/class-main-features.php +++ b/projects/packages/my-jetpack/src/class-main-features.php @@ -473,6 +473,46 @@ public static function get_plugin_status( $slug, $product_class = null ) { : self::PLUGIN_ACTIVE; } + /** + * Whether a plugin is forced on or off where this site's own switch can't change it. + * + * Mirrors `Jetpack_Modules_Overrides`: an `option_active_plugins` filter that adds or + * drops the plugin whatever the stored list says, or a network activation. + * + * @since $$next-version$$ + * + * @param string $slug WordPress.org plugin slug. + * @param string|null $product_class The product behind the plugin, when it has one. + * @return string 'active' or 'inactive' when forced, or an empty string when the owner decides. + */ + public static function get_plugin_override( $slug, $product_class = null ) { + $file = self::get_plugin_file( $slug, $product_class ); + + if ( ! $file ) { + return ''; + } + + if ( 'network-active' === Plugins_Installer::get_plugin_status( $file ) ) { + return self::PLUGIN_ACTIVE; + } + + if ( ! has_filter( 'option_active_plugins' ) ) { + return ''; + } + + /** This filter is documented in wp-includes/option.php */ + if ( in_array( $file, (array) apply_filters( 'option_active_plugins', array(), 'active_plugins' ), true ) ) { + return self::PLUGIN_ACTIVE; + } + + /** This filter is documented in wp-includes/option.php */ + if ( ! in_array( $file, (array) apply_filters( 'option_active_plugins', array( $file ), 'active_plugins' ), true ) ) { + return self::PLUGIN_INACTIVE; + } + + return ''; + } + /** * The installed file for a plugin, by the names its product declares where there is one. * @@ -647,6 +687,7 @@ public static function get_features() { 'plugin_name' => $plugin ? ( $delivery['plugin_name'] ?? $definition['name'] ) : '', 'plugin_url' => $plugin ? ( $delivery['plugin_url'] ?? '' ) : '', 'plugin_status' => $plugin ? self::get_plugin_status( $plugin, $product_class ) : self::PLUGIN_NOT_INSTALLED, + 'plugin_override' => $plugin ? self::get_plugin_override( $plugin, $product_class ) : '', 'paid_highlights' => $definition['paid_highlights'] ?? array(), 'plans' => self::get_plan_badges( $definition ), 'paid_product' => $definition['paid_product'] ?? '', diff --git a/projects/packages/my-jetpack/tests/php/Main_Features_Plugin_Override_Test.php b/projects/packages/my-jetpack/tests/php/Main_Features_Plugin_Override_Test.php new file mode 100644 index 000000000000..4000378e5370 --- /dev/null +++ b/projects/packages/my-jetpack/tests/php/Main_Features_Plugin_Override_Test.php @@ -0,0 +1,91 @@ +clear_options(); + } + + public function test_an_unfiltered_plugin_is_the_owners_to_switch() { + $this->assertSame( '', Main_Features::get_plugin_override( 'jetpack-boost', Boost::class ) ); + } + + public function test_a_plugin_the_host_adds_is_forced_on() { + add_filter( + 'option_active_plugins', + function ( $plugins ) { + $plugins[] = self::PLUGIN_FILE; + return array_unique( $plugins ); + } + ); + + $this->assertSame( 'active', Main_Features::get_plugin_override( 'jetpack-boost', Boost::class ) ); + } + + public function test_a_plugin_the_host_drops_is_forced_off() { + add_filter( + 'option_active_plugins', + function ( $plugins ) { + return array_values( array_diff( $plugins, array( self::PLUGIN_FILE ) ) ); + } + ); + + $this->assertSame( 'inactive', Main_Features::get_plugin_override( 'jetpack-boost', Boost::class ) ); + } + + public function test_a_filter_on_other_plugins_leaves_this_one_alone() { + add_filter( + 'option_active_plugins', + function ( $plugins ) { + $plugins[] = 'akismet/akismet.php'; + return $plugins; + } + ); + + $this->assertSame( '', Main_Features::get_plugin_override( 'jetpack-boost', Boost::class ) ); + } + + public function test_a_missing_plugin_has_no_override() { + add_filter( 'option_active_plugins', '__return_empty_array' ); + + $this->assertSame( '', Main_Features::get_plugin_override( 'zero-bs-crm' ) ); + } + + public function test_the_grid_carries_the_override() { + add_filter( 'option_active_plugins', '__return_empty_array' ); + + $features = array_column( Main_Features::get_features(), 'plugin_override', 'slug' ); + + $this->assertSame( 'inactive', $features['boost'] ); + $this->assertSame( '', $features['stats'] ); + } +} From e9c56f3936f1b13b32780384f6c2e906ddc4ce52 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 22 Sep 2026 12:43:01 -0500 Subject: [PATCH 2/2] My Jetpack: keep forced plugins out of bulk switching and the paid routes The list view's bulk checkbox, select-all and the bulk switch skipped only forced modules, so a forced plugin could still be picked and flipped straight back. The modal's "Available in" also kept showing for a plugin a host forced off. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/feature-list.tsx | 5 +++-- .../features/feature-paid.tsx | 4 ++-- .../features/test/feature-list.test.tsx | 13 +++++++++++++ .../test/feature-modal-sections.test.tsx | 18 ++++++++++++++++++ .../test/use-bulk-feature-switch.test.tsx | 8 ++++++++ .../features/use-bulk-feature-switch.ts | 2 +- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-list.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-list.tsx index 3b9b55da01c3..cbb3dd307b1e 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-list.tsx +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/features/feature-list.tsx @@ -3,6 +3,7 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import { Button, Text, VisuallyHidden } from '@wordpress/ui'; import { useCallback, useMemo, useState } from 'react'; import { FeatureItem } from './feature-item'; +import { getForcedReason } from './feature-state'; import styles from './styles.module.scss'; import { isBulkSwitchable, useBulkFeatureSwitch } from './use-bulk-feature-switch'; import type { FeatureState } from './feature-state'; @@ -42,8 +43,8 @@ function RowCheckbox( { state, isSelected, onSelect }: RowCheckboxProps ) { [ slug, onSelect ] ); - // A forced module has no control to point at, so it gets no checkbox; the hidden one holds the column. - if ( state.control.kind === 'module' && state.control.module.override ) { + // A forced feature has no control to point at, so it gets no checkbox; the hidden one holds the column. + if ( getForcedReason( state ) ) { return (