Skip to content

Protect Dashboard: Add tabbed layout and section framework - #53190

Merged
enejb merged 3 commits into
trunkfrom
add/protect-dashboard-foundation
Oct 8, 2026
Merged

enejb merged 3 commits into
trunkfrom
add/protect-dashboard-foundation

Conversation

@enejb

@enejb enejb commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Part of JETPACK-2875

Contributes to JETPACK-2874

Proposed changes

This is the foundation for the Protect dashboard in packages/protect, which #53182 added. It ships the page shell and a section framework, so each feature (Scan, Monitor, Firewall, Login protection, Scan history) can land as its own PR without touching shared files.

  • Page shell: routes/dashboard/stage.tsx renders AdminPage from @automattic/jetpack-components, with Overview and Settings tabs in the fixed header. It uses the shared jetpack-admin-page-layout-wp-build styles, like Scan, Newsletter and Jetpack AI. The active tab lives in ?tab=; an unknown tab falls back to Overview.
  • PHP sections: each feature is a class in src/sections/class-<name>.php, named Automattic\Jetpack\Protect\Sections\<Name> (class-login-protection.php declares Sections\Login_Protection), implementing Dashboard_Section (get_key(), get_state(), register_routes()). Dashboard::init() finds those files, registers each class once, prints every section's state as window.jetpackProtectDashboard, and registers their REST routes on rest_api_init. Section files only declare their class, so the classmap autoloader can load them safely.
  • Shared PHP helpers on Dashboard: can_manage(), get_module_state(), has_scan_plan(), and Dashboard_Threats, which formats threats for @automattic/jetpack-scan (used by Scan and History).
  • JS sections: routes/dashboard/sections/index.ts already imports one slot per feature. A section can provide an Overview card, a Settings card and/or its own tab, so a feature PR only edits its own folder. In this PR the slots are empty, so both tabs show an empty state.
  • Shared UI: card pieces (ProtectCard, CardRow, Stat), SettingsLink, SettingToggle, IpListField, isModuleActive(), and the settings hook useProtectSettings. The hook fetches the settings the first time the Settings tab opens and saves optimistically, rolling back on failure, to jetpack/v4/settings or another path such as jetpack/v4/waf. route.scss also loads the DataViews styles once for the sections that use them.
  • Dependencies: every dependency the features need is added here, so the feature PRs don't touch lock files:
    • JS (packages/protect): @automattic/jetpack-components, @automattic/jetpack-base-styles, @automattic/jetpack-scan, @wordpress/route, @wordpress/dataviews, @wordpress/ui and friends, plus a Jest setup for the package.
    • PHP (packages/protect): automattic/jetpack-protect-status, automattic/jetpack-protect-models, automattic/jetpack-status.
  • Jetpack plugin assumption: get_module_state() and the settings endpoints are the Jetpack plugin's. The README says so; the Jetpack Protect plugin will need its own equivalents before it can use the page.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

  1. Build and sync the Jetpack plugin to a connected test site, e.g. a Jurassic Ninja site with JETPACK_AUTOLOAD_DEV set.
  2. Turn on the flag (wp companion feature-flag enable jetpack-protect-dashboard) and the module (wp jetpack module activate protect-dashboard).
  3. Open Jetpack → Protect. The header should show the Jetpack logo, "Protect" and the tagline, with Overview and Settings tabs in the fixed header.
  4. Overview should say "There's nothing to show yet." Switch to Settings: the URL should gain p=/?tab=settings and the tab should say "There's nothing to set up yet." Reload, and Settings should stay selected. In the Network panel, jetpack/v4/settings and jetpack/v4/waf should be requested only once the Settings tab is open, not on Overview.
  5. wp eval 'echo wp_json_encode( Automattic\Jetpack\Protect\Dashboard::get_initial_state() );' should print [], since no sections are registered yet, and the page source should contain window.jetpackProtectDashboard = {};. wp eval 'var_dump( Automattic\Jetpack\Protect\Dashboard::has_scan_plan() );' should print false on a site without a Scan plan.
  6. jp test php packages/protect and jp test js packages/protect should pass.

Review follow-ups

Fixed:

  • Sections are registered by Dashboard::init() from side-effect-free classes instead of each file calling register_section() at file scope, which the package's classmap autoloader could otherwise trigger. Loading twice registers each class once.
  • The page's initial state is printed by Dashboard::print_initial_state(), tested for its escaping and for printing {} with no sections.
  • Settings hook: rollback values are captured synchronously from a ref, a save made before the first load is no longer overwritten by that load's response, the settings are only fetched when the Settings tab first opens, and a failed save no longer rolls back a later save of the same key (or clears its busy state).
  • IpListField detects the current IP in comma-separated lists too.
  • Dashboard_Threats::format_all() iterates arrays and Traversable objects instead of casting to an array.
  • register_section() keeps the first section and calls _doing_it_wrong() on a duplicate key.
  • route.scss uses logical sizing and a --wpds-* warning token instead of a hex colour.
  • The Settings link's page slug comes from one named constant.

Deferred:

  • Loading section files only on admin_menu / rest_api_init: the cost is one glob() per request while the flag-gated module is on. Loading lazily needs both the page render and rest_api_init paths to trigger it, so it is better done once the feature PRs have landed.
  • Dependencies and helpers not used in this PR: intentional, so the feature PRs don't touch lock files or shared files.
  • Unit coverage for code nothing uses yet (labelled Coverage tests to be added later): can_manage(), has_scan_plan(), get_module_state(), SettingsLink, SettingToggle, isModuleActive() and the card pieces are first used by the feature PRs, which test them alongside the code that calls them.

Screenshots

Overview tab with the tab strip in the fixed header (no sections registered yet):
Overview tab, empty state
Settings tab:
Settings tab, empty state

@enejb enejb self-assigned this Oct 5, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the add/protect-dashboard-foundation branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack add/protect-dashboard-foundation
bin/jetpack-downloader test jetpack-mu-wpcom-plugin add/protect-dashboard-foundation

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions Bot added [Feature] Protect Dashboard [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

No scheduled milestone found for this plugin.

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@jp-launch-control

jp-launch-control Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 1 file.

File Coverage Δ% Δ Uncovered
projects/packages/protect/src/class-dashboard.php 74/104 (71.15%) 3.99% 8 💔

20 files are newly checked for coverage. Only the first 5 are listed here.

File Coverage
projects/packages/protect/packages/init/src/index.ts 0/1 (0.00%) 💔
projects/packages/protect/routes/dashboard/components/card.tsx 0/3 (0.00%) 💔
projects/packages/protect/routes/dashboard/components/settings-link.tsx 0/8 (0.00%) 💔
projects/packages/protect/routes/dashboard/components/settings/setting-toggle.tsx 0/3 (0.00%) 💔
projects/packages/protect/routes/dashboard/data/is-module-active.ts 0/1 (0.00%) 💔

Full summary · PHP report · JS report

Coverage check overridden by Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR .

kraftbj
kraftbj previously approved these changes Oct 6, 2026
Base automatically changed from add/protect-dashboard-module to trunk October 7, 2026 17:48
@enejb
enejb force-pushed the add/protect-dashboard-foundation branch from c50d3ec to 46219b4 Compare October 7, 2026 18:33
@enejb
enejb force-pushed the add/protect-dashboard-foundation branch from 46219b4 to 10c62dc Compare October 7, 2026 18:42
@enejb enejb added the Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR label Oct 8, 2026
enejb added 3 commits October 8, 2026 07:22
Ports the foundation onto the dashboard's new home in packages/protect: the Dashboard_Section registry, threat formatter, Overview/Settings tabs, settings hook, IP list field and section slots, plus their PHP and JS tests.
Section files now only declare a Sections\<Name> class; Dashboard::init() finds and registers them once, so the classmap autoloader can load them safely. Also print the initial state through a tested method, keep a later save when an earlier save of the same key fails, test the stage's tab resolution, and document the dashboard's dependency on the Jetpack plugin.
@enejb
enejb force-pushed the add/protect-dashboard-foundation branch from b531356 to 6567e4b Compare October 8, 2026 14:23

@kraftbj kraftbj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — these are all optional. The force-push landed every one of the review follow-ups correctly, and nothing in the shipped, reachable code regressed, so merge whenever you're happy with it. I left a few inline suggestions worth a look if you're still iterating:

  • The shared settings hook has a latent rollback edge case (two overlapping failed saves of the same key) that no section can trigger today, but every feature section will inherit it — cheapest to fix here, before the five section PRs build on it.
  • load_sections() skips a misnamed section file silently and would fatal on an abstract or constructor-arg class; a guard that warns via _doing_it_wrong() would fail loud instead.
  • A few test-coverage gaps on claimed fixes (IpListField comma-list detection, the threat field mapping, the tab ?tab= round-trip) and one American-English docblock nit.

None of these block merge — full context in the inline comments.

async ( patch: ProtectSettings, path = '/jetpack/v4/settings' ) => {
const keys = Object.keys( patch );
const before = settingsRef.current;
const previous = Object.fromEntries(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latent rollback edge case — no shipped caller hits it yet (the toggle and IP field disable themselves while saving), but every feature section inherits this hook. previous is captured from settingsRef.current at save time, i.e. the optimistic value. If two saves of the same key both fail, the second save's rollback restores the first save's optimistic value — one the server never accepted. Consider capturing a per-key last-server-confirmed baseline (seeded on load, updated on each save success) and rolling back to that instead. A both-fail concurrent-save test would pin it.


export type DashboardContext< S = unknown > = {
/** This section's state from PHP, or undefined when PHP registered no section by this key. */
state: S;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state is typed S, but as the comment on the line above notes, it is undefined at runtime when PHP registered no section for this key. Typing it S | undefined (or defaulting the lookup in stage.tsx) would make section authors handle the missing-state case at compile time rather than hitting a runtime undefined.

);

describe( 'IpListField', () => {
it( 'keeps the typed draft when its save fails', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comma-separated-list detection and the "Add my IP address" button added in this PR have no test — the only case here is failed-save draft retention. Reverting the split(/[\s,]+/) back to newline-only would still pass CI. An it.each over drafts with currentIp set (comma list with and without the IP, newline list, a substring like 1.2.3.45, empty) plus a click-appends-IP assertion would cover it.

* @param mixed $expected The expected value.
*/
#[DataProvider( 'provider_format' )]
public function test_format( $threat, $path, $expected ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_format asserts only the single field each provider row targets, so the snake_case to camelCase mapping that is the formatter's main job (first_detected to firstDetected, fixed_in to fixedIn, fixed_on to fixedOn, and so on) is never checked end to end. One row that formats a fully-populated threat and assertSames the whole expected camelCase array would guard against a dropped or mis-mapped field.

import { stage as Stage } from '../stage';

jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) );
jest.mock( '@wordpress/route', () => ( { useSearch: jest.fn(), useNavigate: () => jest.fn() } ) );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useNavigate is mocked as a throwaway () => jest.fn(), so the tab-change path added in this PR — writing ?tab=settings and dropping tab for Overview — is never exercised. Hoisting the mock into a shared jest.fn() and clicking each tab (asserting the search updater returns { ...prev, tab: 'settings' } for Settings and clears tab for Overview) would cover the round-trip.

if ( ! class_exists( $class ) ) {
require_once $file;
}
if ( ! is_subclass_of( $class, Dashboard_Section::class ) || self::has_section_of_class( $class ) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two edge cases here. A class-*.php file whose declared class does not match the filename convention is skipped silently (is_subclass_of is false, so continue). And a class that implements the interface but is not instantiable (abstract, or a constructor with required args) would fatal at new $class() on the next line — inside init(), taking the admin menu down with it. A ReflectionClass::isInstantiable() guard plus _doing_it_wrong() on a mismatch (mirroring the duplicate-key notice) would fail loud instead of silent-or-fatal.

}

/**
* A labelled figure, such as a count of blocked requests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

American English: "labelled" should be "labeled", per the repo's spelling convention.

@enejb
enejb merged commit c8b3fb3 into trunk Oct 8, 2026
142 of 143 checks passed
@enejb
enejb deleted the add/protect-dashboard-foundation branch October 8, 2026 15:34
@github-actions github-actions Bot added [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. and removed [Status] In Progress labels Oct 8, 2026
@github-actions github-actions Bot added this to the jetpack/16.4 milestone Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR Docs [Feature] Protect Dashboard [Package] My Jetpack [Package] Protect [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants