Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions projects/packages/my-jetpack/_inc/components/modules-list/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export function FeatureAction( { state, describedby }: FeatureActionProps ) {
);

case 'plugin':
if ( control.override ) {
return null;
}

return (
<PluginToggle
plugin={ control.plugin }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<span className={ styles[ 'row-checkbox-placeholder' ] } aria-hidden="true">
<CheckboxControl __nextHasNoMarginBottom checked={ false } disabled onChange={ onChange } />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function FeatureModalActions( { state }: FeatureModalActionsProps ) {
<ModuleSwitch module={ control.module } name={ feature.name } />
) : null }

{ control.kind === 'plugin' ? (
{ control.kind === 'plugin' && ! forcedReason ? (
<PluginSwitch plugin={ control.plugin } isOn={ isActive } name={ pluginName } />
) : null }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { starFilled } from '@wordpress/icons';
import { Badge, Icon, Stack, Text } from '@wordpress/ui';
import { useCallback } from 'react';
import { PRODUCT_STATUSES } from '../../../constants';
import { getForcedReason } from './feature-state';
import styles from './styles.module.scss';
import type { FeatureState } from './feature-state';
import type { FeatureFilter } from './use-feature-filter';
Expand Down Expand Up @@ -68,8 +69,7 @@ export function FeaturePaid( { state, onFilterByPlan }: FeaturePaidProps ) {
const isPaidOnly = state.product?.status === PRODUCT_STATUSES.NEEDS_PLAN;
const highlights = isPaidOnly ? [] : ( feature.paid_highlights ?? [] );
// A host that forced it off decides this, not a purchase.
const isForcedOff =
state.control.kind === 'module' && state.control.module.override === 'inactive';
const isForcedOff = state.status !== 'active' && !! getForcedReason( state );
const routes = ! isForcedOff && ( plans.length > 0 || !! paidProduct );

if ( ! highlights.length && ! routes ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' };
Expand All @@ -41,17 +41,23 @@ 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.
*/
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;
}

/**
Expand Down Expand Up @@ -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,
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FeatureAction
state={ buildState(
{ kind: 'plugin', plugin: 'jetpack-boost', override: 'active' },
{ status: 'active' }
) }
/>
);

expect( container ).toBeEmptyDOMElement();
} );

it( 'renders nothing when this site cannot switch the feature', () => {
const { container } = render( <FeatureAction state={ buildState( { kind: 'none' } ) } /> );

Expand Down Expand Up @@ -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(
<FeatureModalActions
state={ buildState(
{ kind: 'plugin', plugin: 'jetpack-boost', override: 'active' },
{ status: 'active' }
) }
/>
);

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(
<FeatureModalActions
state={ buildState( { kind: 'plugin', plugin: 'jetpack-boost', override: 'inactive' } ) }
/>
);

expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );

it( 'leaves the reason for a module forced off to "How to get it"', () => {
const forcedOff = {
...forcedModule,
Expand Down Expand Up @@ -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(
<FeatureItem
state={ buildState( { kind: 'plugin', plugin: 'jetpack-boost', override: 'inactive' } ) }
onOpen={ jest.fn() }
/>
);

expect( screen.getByText( 'Inactive' ) ).toBeInTheDocument();
expect( screen.queryByText( /by your host or site administrator/ ) ).not.toBeInTheDocument();
expect( screen.queryByRole( 'checkbox' ) ).not.toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ describe( 'FeatureList', () => {
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1 selected' );
} );

it( 'offers no checkbox for a plugin a host forced on or off', async () => {
const forcedBoost = pluginState( 'boost', 'active', {
control: { kind: 'plugin', plugin: 'boost', override: 'active' },
} );
render( <FeatureList states={ [ akismet, forcedBoost ] } onOpen={ jest.fn() } /> );

expect( screen.queryByRole( 'checkbox', { name: 'Select boost' } ) ).not.toBeInTheDocument();

await userEvent.click( checkbox( 'Select all features' ) );

expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1 selected' );
} );

it( 'shows select-all as partly checked when only some rows are picked', async () => {
render( <FeatureList states={ [ akismet, boost ] } onOpen={ jest.fn() } /> );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ const moduleState = ( override: false | 'active' | 'inactive', status: 'active'
},
} ) as FeatureState;

const forcedOffPlugin = {
feature: { ...feature, slug: 'boost', name: 'Boost', in_jetpack: false, plugin: 'jetpack-boost' },
status: 'inactive',
control: { kind: 'plugin', plugin: 'jetpack-boost', override: 'inactive' },
} as FeatureState;

describe( 'FeatureDelivery', () => {
it( 'says why a module a host forced off cannot be turned on, instead of how to', () => {
render( <FeatureDelivery state={ moduleState( 'inactive', 'inactive' ) } /> );
Expand All @@ -33,6 +39,12 @@ describe( 'FeatureDelivery', () => {
expect( screen.queryByText( 'In Jetpack' ) ).not.toBeInTheDocument();
} );

it( 'says why a plugin a host forced off cannot be turned on', () => {
render( <FeatureDelivery state={ forcedOffPlugin } /> );

expect( screen.getByText( 'Disabled by your host or site administrator' ) ).toBeInTheDocument();
} );

it( 'still explains how to turn on a module nobody forced', () => {
render( <FeatureDelivery state={ moduleState( false, 'inactive' ) } /> );

Expand All @@ -49,6 +61,12 @@ describe( 'FeaturePaid', () => {
expect( screen.queryByText( 'Available in' ) ).not.toBeInTheDocument();
} );

it( 'leaves out "Available in" for a plugin a host forced off', () => {
render( <FeaturePaid state={ forcedOffPlugin } onFilterByPlan={ jest.fn() } /> );

expect( screen.queryByText( 'Available in' ) ).not.toBeInTheDocument();
} );

it( 'keeps "Available in" for a module nobody forced', () => {
render(
<FeaturePaid state={ moduleState( false, 'inactive' ) } onFilterByPlan={ jest.fn() } />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } ),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ describe( 'isBulkSwitchable', () => {
[ 'a module with a plain switch', moduleState( 'stats', 'inactive' ), true ],
[ 'a plugin switch', pluginState( 'akismet', 'active' ), true ],
[ 'a forced module', moduleState( 'stats', 'active', { override: 'active' } ), false ],
[
'a forced plugin',
{
...pluginState( 'akismet', 'inactive' ),
control: { kind: 'plugin', plugin: 'akismet', override: 'inactive' },
} as FeatureState,
false,
],
[
'a feature still loading',
{ ...moduleState( 'stats', 'inactive' ), pending: true } as FeatureState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function isBulkSwitchable( state: FeatureState ): boolean {
return hasPlainSwitch( control.module );
}

return control.kind === 'plugin';
return control.kind === 'plugin' && ! control.override;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions projects/packages/my-jetpack/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading