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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Protect dashboard: Open the Settings tab at a section's card from a link, and point the Configure links of Jetpack's security modules at their card.
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
import { useEffect, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Notice, Spinner, Stack, Text } from '@wordpress/ui';
import sections from '../sections';
import type { DashboardContext } from '../sections/types';

/**
* The id of the element wrapping a section's settings card.
*
* @param key - The section key.
* @return The id.
*/
const cardId = ( key: string ) => `jp-protect-settings-${ key }`;

/**
* The Settings tab: each section's settings card, once Jetpack's settings have loaded.
*
* @param props - The dashboard context, without a section's state.
* @param props - The dashboard context, without a section's state.
* @param props.section - The key of the section whose card to scroll to and focus.
* @return The tab.
*/
export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) {
export default function SettingsTab( {
section,
...props
}: Omit< DashboardContext, 'state' > & { section?: string } ) {
const { isLoaded, error, dismissError, load } = props.settings;
const state = window.jetpackProtectDashboard ?? {};
const cards = sections.filter( section => section.SettingsCard );
const cards = sections.filter( item => item.SettingsCard );
// Once per arrival, so a later save doesn't pull the page back to the card.
const scrolledTo = useRef< string >();

useEffect( () => {
if ( ! isLoaded || ! section || scrolledTo.current === section ) {
return;
}
const card = document.getElementById( cardId( section ) );
const heading = card?.querySelector< HTMLElement >( 'h2' );
if ( ! card || ! heading ) {
return;
}
scrolledTo.current = section;
// Stop below the sticky tab strip, at the body's 24px padding, or the strip covers the card's title.
const strip = document.querySelector< HTMLElement >( '.jp-admin-page-tabs' );
card.style.scrollMarginBlockStart = `${ ( strip?.offsetHeight ?? 0 ) + 24 }px`;
card.scrollIntoView( { block: 'start' } );
// The heading names the card for screen readers; -1 makes it focusable without a tab stop.
heading.tabIndex = -1;
heading.focus( { preventScroll: true } );
}, [ isLoaded, section ] );

if ( ! isLoaded ) {
return error ? (
Expand Down Expand Up @@ -43,7 +77,9 @@ export default function SettingsTab( props: Omit< DashboardContext, 'state' > )
) }
{ cards.length ? (
cards.map( ( { key, SettingsCard: Card } ) => (
<Card key={ key } { ...props } state={ state[ key ] } />
<div key={ key } id={ cardId( key ) }>
<Card { ...props } state={ state[ key ] } />
</div>
) )
) : (
<Text variant="body-md">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const UNAVAILABLE: FirewallState = {
// One id for the test's snackbars, so the result replaces "Testing the firewall…".
const NOTICE_ID = 'jetpack-protect-firewall-test';

/** Opens the Settings tab at this section's card. */
const SETTINGS_PARAMS = { section: 'firewall' };

type NoticeStatus = 'success' | 'info' | 'warning' | 'error';

// Icon SVGs default to black; currentColor picks up the snackbar's light text.
Expand Down Expand Up @@ -287,7 +290,7 @@ export default function FirewallOverviewCard( { state, settings, openTab }: Fire
label={ __( 'All-time blocked requests', 'jetpack-protect-pkg' ) }
value={ blocks.blockedCount === null ? '—' : formatNumber( blocks.blockedCount ) }
/>
<TabLink tab="settings" onOpen={ openTab }>
<TabLink tab="settings" params={ SETTINGS_PARAMS } onOpen={ openTab }>
{ __( 'Configure Firewall', 'jetpack-protect-pkg' ) }
</TabLink>
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,13 @@ describe( 'FirewallOverviewCard', () => {

expect( screen.getByText( /^1.?234$/ ) ).toBeInTheDocument();
} );

it( 'opens the Settings tab at its own card', async () => {
const openTab = jest.fn();
render( <FirewallOverviewCard { ...props } openTab={ openTab } /> );

await userEvent.setup().click( screen.getByRole( 'link', { name: 'Configure Firewall' } ) );

expect( openTab ).toHaveBeenCalledWith( 'settings', { section: 'firewall' } );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import isModuleActive from '../../data/is-module-active';
import type { LoginProtectionContext, ModuleState } from './types';
import type { CardStatus } from '../../components/card';

/** Opens the Settings tab at this section's card. */
const SETTINGS_PARAMS = { section: 'loginProtection' };

/**
* One way of protecting logins, with whether it's on.
*
Expand Down Expand Up @@ -139,7 +142,7 @@ export default function LoginProtectionCard( {
state={ sso }
/>
<CardRow>
<TabLink tab="settings" onOpen={ openTab }>
<TabLink tab="settings" params={ SETTINGS_PARAMS } onOpen={ openTab }>
{ __( 'Configure login protection', 'jetpack-protect-pkg' ) }
</TabLink>
</CardRow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,6 @@ describe( 'LoginProtectionCard', () => {
.setup()
.click( screen.getByRole( 'link', { name: 'Configure login protection' } ) );

expect( openTab ).toHaveBeenCalledWith( 'settings', undefined );
expect( openTab ).toHaveBeenCalledWith( 'settings', { section: 'loginProtection' } );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const CONNECT_URL = 'admin.php?page=my-jetpack#/connection';
const MINUTES_PER_DAY = 1440;
const PERCENT = { style: 'percent', maximumFractionDigits: 3 } as const;

/** Opens the Settings tab at this section's card. */
const SETTINGS_PARAMS = { section: 'monitor' };

/**
* A day's date, in the site's date format.
*
Expand Down Expand Up @@ -264,7 +267,7 @@ export default function MonitorCard( { state: monitor, settings, openTab }: Moni
</>
) }
<div>
<TabLink tab="settings" onOpen={ openTab }>
<TabLink tab="settings" params={ SETTINGS_PARAMS } onOpen={ openTab }>
{ __( 'Configure downtime monitoring', 'jetpack-protect-pkg' ) }
</TabLink>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const days: UptimeDay[] = [

const renderCard = (
settings: Partial< ProtectSettingsData > = {},
state: Partial< MonitorState > = {}
state: Partial< MonitorState > = {},
openTab = jest.fn()
) =>
render(
<MonitorCard
Expand All @@ -31,7 +32,7 @@ const renderCard = (
...settings,
} as ProtectSettingsData
}
openTab={ jest.fn() }
openTab={ openTab }
/>
);

Expand Down Expand Up @@ -143,4 +144,16 @@ describe( 'MonitorCard', () => {

expect( mockApiFetch ).not.toHaveBeenCalled();
} );

it( 'opens the Settings tab at its own card', async () => {
mockApiFetch.mockResolvedValue( { days, isUp: true } );
const openTab = jest.fn();
renderCard( {}, {}, openTab );

await userEvent
.setup()
.click( screen.getByRole( 'link', { name: 'Configure downtime monitoring' } ) );

expect( openTab ).toHaveBeenCalledWith( 'settings', { section: 'monitor' } );
} );
} );
8 changes: 7 additions & 1 deletion projects/packages/protect/routes/dashboard/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import './route.scss';
/**
* Boot stage for the Protect dashboard: Overview, any section tabs, then Settings; the active one in `?tab=`.
*
* `?section=` names the card the Settings tab scrolls to, which links from other screens use.
*
* @return The Protect page.
*/
const Stage = () => {
// `@wordpress/route` types no route tree, so `from` and the navigate argument below need a cast.
const search: Record< string, unknown > = useSearch( { from: '/' as never, strict: false } );
const requestedTab = typeof search.tab === 'string' ? search.tab : '';
const requestedSection = typeof search.section === 'string' ? search.section : undefined;
const navigate = useNavigate();
const openTab = useCallback(
( next: string, params?: Record< string, string > ) => {
Expand All @@ -27,6 +30,7 @@ const Stage = () => {
...prev,
// Leaving a tab closes the inspector, whose threat belongs to the list on that tab.
...CLOSED_INSPECTOR,
section: undefined,
...params,
tab: next === 'overview' ? undefined : next,
} ),
Expand Down Expand Up @@ -86,7 +90,9 @@ const Stage = () => {
</Tabs.Panel>
) ) }
<Tabs.Panel value="settings">
{ activeTab === 'settings' && <SettingsTab { ...context } /> }
{ activeTab === 'settings' && (
<SettingsTab { ...context } section={ requestedSection } />
) }
</Tabs.Panel>
</div>
</Tabs.Root>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import SettingsTab from '../components/settings-tab';

jest.mock( '../sections', () => ( {
__esModule: true,
default: [ 'firewall', 'monitor' ].map( key => ( {
key,
SettingsCard: () => <h2>{ key }</h2>,
} ) ),
} ) );

const settings = ( overrides = {} ) =>
( {
isLoaded: true,
error: '',
dismissError: jest.fn(),
load: jest.fn(),
...overrides,
} ) as never;

describe( 'Protect dashboard Settings tab', () => {
const scrollIntoView = jest.fn();

beforeEach( () => {
scrollIntoView.mockReset();
Element.prototype.scrollIntoView = scrollIntoView;
} );

it( 'lands on the requested card once, not again after a save', () => {
const { rerender } = render(
<SettingsTab settings={ settings() } openTab={ jest.fn() } section="monitor" />
);

expect( screen.getByRole( 'heading', { name: 'monitor' } ) ).toHaveFocus();
expect( scrollIntoView ).toHaveBeenCalledTimes( 1 );
expect( scrollIntoView.mock.contexts[ 0 ] ).toHaveAttribute(
'id',
'jp-protect-settings-monitor'
);

rerender(
<SettingsTab
settings={ settings( { isSaving: true } ) }
openTab={ jest.fn() }
section="monitor"
/>
);

expect( scrollIntoView ).toHaveBeenCalledTimes( 1 );
} );
} );
71 changes: 71 additions & 0 deletions projects/packages/protect/src/class-dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ class Dashboard {
*/
const RENDER_FUNCTION = 'jetpack_protect_jetpack_protect_dashboard_wp_admin_render_page';

/**
* The settings section that owns each Jetpack security module, for links from other screens.
*
* @since $$next-version$$
*
* @var array<string, string>
*/
const MODULE_SECTIONS = array(
'waf' => 'firewall',
'protect' => 'loginProtection',
'account-protection' => 'loginProtection',
'sso' => 'loginProtection',
'monitor' => 'monitor',
);

/**
* The Jetpack filter that sets a module's Configure link, minus the module slug.
*
* @var string
*/
const CONFIGURATION_URL_FILTER = 'jetpack_module_configuration_url_';

/**
* Extra Admin_Menu item options, such as the module that owns the item.
*
Expand Down Expand Up @@ -87,6 +109,12 @@ public static function init( array $menu_options = array() ) {
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
Threat_Count::init();

foreach ( array_keys( self::MODULE_SECTIONS ) as $module ) {
add_filter( self::CONFIGURATION_URL_FILTER . $module, array( __CLASS__, 'filter_module_configuration_url' ) );
// Firewall and Account protection have no Configure link otherwise.
add_filter( 'jetpack_module_configurable_' . $module, '__return_true' );
}

self::load_sections( __DIR__ . '/sections' );

/**
Expand Down Expand Up @@ -197,6 +225,49 @@ public static function can_manage() {
return current_user_can( 'manage_options' );
}

/**
* The Settings tab's URL, scrolled to a section's card.
*
* @since $$next-version$$
*
* @param string $section Section key, such as `firewall`; empty for the top of the tab.
* @return string
*/
public static function get_settings_url( $section = '' ) {
$query = array( 'tab' => 'settings' );
if ( '' !== $section ) {
$query['section'] = $section;
}

return admin_url( 'admin.php?page=' . self::MENU_SLUG . '&p=' . rawurlencode( '/?' . http_build_query( $query ) ) );
}

/**
* Each security module's card on the Settings tab, for screens that link here instead of offering the setting.
*
* @since $$next-version$$
*
* @return array<string, string> Settings URLs keyed by module slug.
*/
public static function get_module_settings_urls() {
return array_map( array( __CLASS__, 'get_settings_url' ), self::MODULE_SECTIONS );
}

/**
* Point a security module's Configure link at its card on the Settings tab.
*
* @since $$next-version$$
*
* @param string $url The URL Jetpack would use.
* @return string
*/
public static function filter_module_configuration_url( $url ) {
$module = substr( current_filter(), strlen( self::CONFIGURATION_URL_FILTER ) );
$urls = self::get_module_settings_urls();

return $urls[ $module ] ?? $url;
}

/**
* Whether the site has a plan that includes Scan.
*
Expand Down
Loading
Loading