From 512eea6cb0ab7fbdbbbf6b95857fef64a480574a Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 16 Oct 2026 13:59:38 -0500 Subject: [PATCH 1/4] Protect Dashboard: Link straight to a section's settings card Add a `section` search param that the Settings tab scrolls to and focuses, a Dashboard::get_settings_url() helper that builds those links, and point the Configure links of the five security modules at their card. The overview cards' own Configure links now land on their card too. --- ...rotect-settings-section-links-JETPACK-2886 | 4 ++ .../dashboard/components/settings-tab.tsx | 41 +++++++++++-- .../sections/firewall/overview-card.tsx | 5 +- .../login-protection-card.tsx | 5 +- .../test/login-protection-card.test.tsx | 2 +- .../sections/monitor/monitor-card.tsx | 5 +- .../protect/routes/dashboard/stage.tsx | 8 ++- .../dashboard/test/settings-tab.test.tsx | 51 ++++++++++++++++ .../packages/protect/src/class-dashboard.php | 59 +++++++++++++++++++ .../protect/tests/php/Dashboard_Test.php | 34 +++++++++++ 10 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 projects/packages/protect/changelog/add-protect-settings-section-links-JETPACK-2886 create mode 100644 projects/packages/protect/routes/dashboard/test/settings-tab.test.tsx diff --git a/projects/packages/protect/changelog/add-protect-settings-section-links-JETPACK-2886 b/projects/packages/protect/changelog/add-protect-settings-section-links-JETPACK-2886 new file mode 100644 index 000000000000..1adf0383eca7 --- /dev/null +++ b/projects/packages/protect/changelog/add-protect-settings-section-links-JETPACK-2886 @@ -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. diff --git a/projects/packages/protect/routes/dashboard/components/settings-tab.tsx b/projects/packages/protect/routes/dashboard/components/settings-tab.tsx index 79a9b7091e9c..6f2ca2b85c0b 100644 --- a/projects/packages/protect/routes/dashboard/components/settings-tab.tsx +++ b/projects/packages/protect/routes/dashboard/components/settings-tab.tsx @@ -1,18 +1,49 @@ +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; + 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 ? ( @@ -41,7 +72,9 @@ export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) ) } { cards.length ? ( cards.map( ( { key, SettingsCard: Card } ) => ( - +
+ +
) ) ) : ( diff --git a/projects/packages/protect/routes/dashboard/sections/firewall/overview-card.tsx b/projects/packages/protect/routes/dashboard/sections/firewall/overview-card.tsx index 2c253302e3d1..0e2d2493d368 100644 --- a/projects/packages/protect/routes/dashboard/sections/firewall/overview-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/firewall/overview-card.tsx @@ -37,6 +37,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. @@ -308,7 +311,7 @@ export default function FirewallOverviewCard( { state, settings, openTab }: Fire label={ __( 'All-time blocked requests', 'jetpack-protect-pkg' ) } value={ blocks.blockedCount === null ? '—' : formatCount( blocks.blockedCount ) } /> - + { __( 'Configure Firewall', 'jetpack-protect-pkg' ) } diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx index 62107a7311f9..2987b2fa0d73 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx @@ -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. * @@ -139,7 +142,7 @@ export default function LoginProtectionCard( { state={ sso } /> - + { __( 'Configure login protection', 'jetpack-protect-pkg' ) } diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx index d332aed8f1fa..a8e9f52bfc6b 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx @@ -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' } ); } ); } ); diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx index ff69d5b77323..1fd98f10a8d3 100644 --- a/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx @@ -14,6 +14,9 @@ import './style.scss'; const UPTIME_PATH = '/jetpack/v4/protect-dashboard/uptime'; const CONNECT_URL = 'admin.php?page=my-jetpack#/connection'; +/** Opens the Settings tab at this section's card. */ +const SETTINGS_PARAMS = { section: 'monitor' }; + /** * Text for one day's bar. * @@ -234,7 +237,7 @@ export default function MonitorCard( { state: monitor, settings, openTab }: Moni { available && ( - + { active ? __( 'Configure Downtime Monitoring', 'jetpack-protect-pkg' ) : __( 'Turn on in Settings', 'jetpack-protect-pkg' ) } diff --git a/projects/packages/protect/routes/dashboard/stage.tsx b/projects/packages/protect/routes/dashboard/stage.tsx index 5e0451f395cc..c733a80b9715 100644 --- a/projects/packages/protect/routes/dashboard/stage.tsx +++ b/projects/packages/protect/routes/dashboard/stage.tsx @@ -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 > ) => { @@ -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, } ), @@ -86,7 +90,9 @@ const Stage = () => { ) ) } - { activeTab === 'settings' && } + { activeTab === 'settings' && ( + + ) } diff --git a/projects/packages/protect/routes/dashboard/test/settings-tab.test.tsx b/projects/packages/protect/routes/dashboard/test/settings-tab.test.tsx new file mode 100644 index 000000000000..4a35af2b832f --- /dev/null +++ b/projects/packages/protect/routes/dashboard/test/settings-tab.test.tsx @@ -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: () =>

{ key }

, + } ) ), +} ) ); + +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( + + ); + + expect( screen.getByRole( 'heading', { name: 'monitor' } ) ).toHaveFocus(); + expect( scrollIntoView ).toHaveBeenCalledTimes( 1 ); + expect( scrollIntoView.mock.contexts[ 0 ] ).toHaveAttribute( + 'id', + 'jp-protect-settings-monitor' + ); + + rerender( + + ); + + expect( scrollIntoView ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/projects/packages/protect/src/class-dashboard.php b/projects/packages/protect/src/class-dashboard.php index 699070a35c30..70fbd0142a26 100644 --- a/projects/packages/protect/src/class-dashboard.php +++ b/projects/packages/protect/src/class-dashboard.php @@ -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 + */ + 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. * @@ -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' ); /** @@ -197,6 +225,37 @@ 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 ) ) ); + } + + /** + * 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 ) ); + + return isset( self::MODULE_SECTIONS[ $module ] ) ? self::get_settings_url( self::MODULE_SECTIONS[ $module ] ) : $url; + } + /** * Whether the site has a plan that includes Scan. * diff --git a/projects/packages/protect/tests/php/Dashboard_Test.php b/projects/packages/protect/tests/php/Dashboard_Test.php index 09a2553749da..643952535290 100644 --- a/projects/packages/protect/tests/php/Dashboard_Test.php +++ b/projects/packages/protect/tests/php/Dashboard_Test.php @@ -42,6 +42,10 @@ public function tear_down() { remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( 'doing_it_wrong_trigger_error' ); remove_all_actions( 'admin_enqueue_scripts' ); + foreach ( array_keys( Dashboard::MODULE_SECTIONS ) as $module ) { + remove_all_filters( Dashboard::CONFIGURATION_URL_FILTER . $module ); + remove_all_filters( 'jetpack_module_configurable_' . $module ); + } foreach ( array( 'jetpack_page_', 'admin_page_' ) as $prefix ) { remove_all_actions( 'load-' . $prefix . Dashboard::MENU_SLUG ); remove_all_actions( 'load-' . $prefix . Dashboard::MENU_SLUG . '-network' ); @@ -136,6 +140,36 @@ public function test_add_menu_leaves_one_item_that_renders_the_dashboard( $regis $this->assertFalse( Admin_Menu::remove_menu( Dashboard::MENU_SLUG ) ); } + /** + * Each security module and the card its Configure link should open. + * + * @return array[] + */ + public static function provide_security_modules() { + return array( + 'Firewall' => array( 'waf', 'firewall' ), + 'Brute force' => array( 'protect', 'loginProtection' ), + 'Account protection' => array( 'account-protection', 'loginProtection' ), + 'WordPress.com login' => array( 'sso', 'loginProtection' ), + 'Downtime monitoring' => array( 'monitor', 'monitor' ), + ); + } + + /** + * @dataProvider provide_security_modules + * + * @param string $module Module slug. + * @param string $section The section whose card should open. + */ + #[DataProvider( 'provide_security_modules' )] + public function test_security_module_configure_links_open_their_settings_card( $module, $section ) { + $this->assertSame( + admin_url( 'admin.php?page=jetpack-protect&p=%2F%3Ftab%3Dsettings%26section%3D' . $section ), + apply_filters( 'jetpack_module_configuration_url_' . $module, 'https://example.com/default' ) + ); + $this->assertTrue( apply_filters( 'jetpack_module_configurable_' . $module, false ) ); + } + public function test_init_announces_the_dashboard() { $this->assertGreaterThan( 0, did_action( 'jetpack_protect_dashboard_initialized' ) ); } From 9f48449862fa6a265239b530563a127d7d6fc4e1 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 16 Oct 2026 14:01:13 -0500 Subject: [PATCH 2/4] Protect Dashboard: Expose each security module's settings URL --- projects/packages/protect/src/class-dashboard.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/projects/packages/protect/src/class-dashboard.php b/projects/packages/protect/src/class-dashboard.php index 70fbd0142a26..23d0af3a5e81 100644 --- a/projects/packages/protect/src/class-dashboard.php +++ b/projects/packages/protect/src/class-dashboard.php @@ -242,6 +242,17 @@ public static function get_settings_url( $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 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. * @@ -252,8 +263,9 @@ public static function get_settings_url( $section = '' ) { */ public static function filter_module_configuration_url( $url ) { $module = substr( current_filter(), strlen( self::CONFIGURATION_URL_FILTER ) ); + $urls = self::get_module_settings_urls(); - return isset( self::MODULE_SECTIONS[ $module ] ) ? self::get_settings_url( self::MODULE_SECTIONS[ $module ] ) : $url; + return $urls[ $module ] ?? $url; } /** From ef5e5ee76f686a4cf5e8efc507e930025d1b0971 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 16 Oct 2026 14:31:40 -0500 Subject: [PATCH 3/4] Protect Dashboard: Land the linked card below the sticky tab strip --- .../protect/routes/dashboard/components/settings-tab.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/projects/packages/protect/routes/dashboard/components/settings-tab.tsx b/projects/packages/protect/routes/dashboard/components/settings-tab.tsx index 6f2ca2b85c0b..283276301201 100644 --- a/projects/packages/protect/routes/dashboard/components/settings-tab.tsx +++ b/projects/packages/protect/routes/dashboard/components/settings-tab.tsx @@ -39,6 +39,9 @@ export default function SettingsTab( { 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; From 9509f4202c5b574d24a2f9d04095e7912179a3be Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 19 Oct 2026 12:43:04 -0500 Subject: [PATCH 4/4] Protect Dashboard: Test the Monitor and Firewall links open their card --- .../firewall/test/overview-card.test.tsx | 9 +++++++++ .../sections/monitor/test/monitor-card.test.tsx | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/projects/packages/protect/routes/dashboard/sections/firewall/test/overview-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/firewall/test/overview-card.test.tsx index febc9db0f54a..b1d9c1dc7c72 100644 --- a/projects/packages/protect/routes/dashboard/sections/firewall/test/overview-card.test.tsx +++ b/projects/packages/protect/routes/dashboard/sections/firewall/test/overview-card.test.tsx @@ -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( ); + + await userEvent.setup().click( screen.getByRole( 'link', { name: 'Configure Firewall' } ) ); + + expect( openTab ).toHaveBeenCalledWith( 'settings', { section: 'firewall' } ); + } ); } ); diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx index 08dac3ebbcf5..296ea52c9259 100644 --- a/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx +++ b/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx @@ -18,7 +18,8 @@ const days: UptimeDay[] = [ const renderCard = ( settings: Partial< ProtectSettingsData > = {}, - state: Partial< MonitorState > = {} + state: Partial< MonitorState > = {}, + openTab = jest.fn() ) => render( ); @@ -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' } ); + } ); } );