Skip to content

Scan: port Calypso overview to wp-admin (Phases 0–8)#48458

Draft
ilonagl wants to merge 34 commits intotrunkfrom
try/jetpack-scan-new-ui
Draft

Scan: port Calypso overview to wp-admin (Phases 0–8)#48458
ilonagl wants to merge 34 commits intotrunkfrom
try/jetpack-scan-new-ui

Conversation

@ilonagl
Copy link
Copy Markdown
Contributor

@ilonagl ilonagl commented May 1, 2026

Part of #48456

Multi-phase port of Calypso's Scan dashboard onto a native Jetpack wp-admin page. Mirrors the architecture of projects/packages/activity-log/ + the in-flight Backup port (#48236): a TanStack Query data layer, hash-routed Tabs.Root, AdminPage shell, ?jps-mock=1 for design iteration, snackbar notices via core/notices. Each phase below lands as a single commit on this branch.

Phases

  • Phase 0 (ff804e0e9a) — scaffold projects/packages/scan/ (@automattic/jetpack-scan-page, namespace Automattic\Jetpack\Scan_Page, textdomain jetpack-scan-page). PHP shell registers the ?page=jetpack-scan submenu via Admin_Menu::add_menu (position 6, gated on connected admin / non-multisite) and seeds JPSCAN_INITIAL_STATE. JS shell wires createHashRouter + RouterProvider + AdminPage + HeaderActionsProvider + Gates. Data skeleton (fetchers / query-options / types / use-site-data / use-track-event) and mock fixtures with the ?jps-mock=1 flag. plugins/jetpack/composer.json + class.jetpack.php register the new package alongside Activity_Log_Init.
  • Phase 1 (7945347e36) — REST bridges for GET /scan, /scan/history, /scan/counts proxied to wpcom/v2 /sites/:siteId/scan* via Client::wpcom_json_api_request_as_user with X-Forwarded-For audit-log alignment. Tabbed Active threats / Scan history overview using ThreatsDataViews from @automattic/jetpack-scan (the existing js-package — issue's decision Allow plugins to inject additional template-specific open graph tags #2, thin-wrapper choice). URL-synced via ?tab=.
  • Phase 1.5 (06534e610b) — switched the tab nav from legacy TabPanel to the @wordpress/ui Tabs.Root / Tabs.List / Tabs.Tab / Tabs.Panel minimal-underline pattern from Newsletter: unify Subscribers + Settings into a single wp-admin product #48420 phase 3, so the active-tab indicator slides smoothly between Active and History.
  • Phase 1.6 (40aa31b959) — Forms-style empty state (VStack + Text 15px/500 heading + muted body) wired into both panels via a new optional empty?: ReactNode prop on ThreatsDataViews (additive, default-preserving — Protect plugin keeps working).
  • Phase 1.7 (2b7c0546e5) — full-page tab panels: Tabs.Root + content slot + active Tabs.Panel are now flex columns with flex: 1 1 auto + min-block-size: 0, so DataViews (height: 100% internally) fills the page and centers its empty body. Dropped the wrapper's horizontal padding so DataViews' internal padding: 16px 24px aligns the search row with the tab labels at the same 24 px inset.
  • Phase 3 (bbf223544a) — single-threat row actions. New REST bridges: POST /threat/{id}/ignore, /threat/{id}/unignore, /threats/fix, GET /threats/fix-status (proxy to wpcom/v2 /sites/:siteId/alerts/*). The four read paths from Phase 1 refactored onto shared proxy_get / proxy_post helpers. JS hooks: useFixThreatsMutation / useIgnoreThreatMutation / useUnignoreThreatMutation (cache-invalidating on success), useFixThreatsStatusQuery (2 s polling that stops on terminal status). useThreatActions bundles them into onFixThreats / onIgnoreThreats / onUnignoreThreats callbacks compatible with ThreatsDataViews' row-action props, with core/notices snackbar feedback. notices-list.tsx mounts a <SnackbarList> once at the bottom of the AdminPage chrome so any descendant can surface snackbars.
  • Phase 4 (8d07cd00ae) — bulk-fix modal + "Auto-fix N threats" header CTA. BulkFixModal confirms the fixable list (non-fixable threats are filtered + flagged via an info Notice), kicks useFixThreatsMutation, swaps to a "Fixing threats…" progress step driven by useFixThreatsStatusQuery, then a "Done" summary showing fixed-vs-total when polling settles. Header CTA scoped to the Active tab via the shared HeaderActionsProvider.
  • Phase 5 (187deb162f) — Scan-now CTA + in-progress UI. POST /jetpack/v4/site/scan/enqueue REST bridge, useEnqueueScanMutation, and a <ScanNowButton> (always visible on the Active tab, disabled while a scan is running, snackbar feedback). <ScanStatus> (spinner + heading + muted body + a ProgressBar driven off current.progress) replaces the threats table while state is enqueued | running.
  • Phase 6 (12dc6bdc8f) — silence the standard wp-admin notice channels (admin_notices / all_admin_notices) on the Scan page so JITMs and plugin-update messages don't reflow the focused layout. Same Forms-style pattern Jetpack Forms uses on its dashboard.
  • Phase 7 (0f19daba66) — wire jetpack_scan_* Tracks events: _scan_now, _fix_threats_cta_click ({ threat_count }), _bulk_fix_threats_modal_open / _click / _success ({ threat_count, fixed_count, failed_count }) / _failed. data/use-track-event.ts switched from a hand-rolled _tkq shim to @automattic/jetpack-analytics (the canonical Jetpack tracking client used by Forms / Backup / Activity Log).
  • Phase 8 (e22e857009) — test scaffolding. Jetpack_Scan_Bridges_Test covers the admin-only permission callback (anon + subscriber rejected, admin allowed) and route registration for every Scan endpoint (9 tests, 16 assertions). Jest unit tests for isFixComplete cover undefined / empty / partial / terminal / unknown-status edges (5 tests). composer phpunit + pnpm test scripts wired up. Broader bridge coverage and e2e Playwright flows are left for a follow-up PR per the issue's Phase 8 plan.

Decisions resolved

Decisions still open

Related product discussion/links

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

  • REST surface. All /jetpack/v4/site/scan/* bridges proxy existing WPCOM endpoints (/sites/:siteId/scan* and /sites/:siteId/alerts/*) through the site's Jetpack connection — same proxy mechanism Activity Log and Backup already use. No new data is collected, transmitted, or stored beyond what Calypso's Scan dashboard already sends.
  • Tracks events. Phase 7 introduces jetpack_scan_* events: _scan_now, _fix_threats_cta_click ({ threat_count }), _bulk_fix_threats_modal_open / _click ({ threat_count }), _bulk_fix_threats_modal_success ({ threat_count, fixed_count, failed_count }), _bulk_fix_threats_modal_failed ({ threat_count }). All counts are integers; no PII, no threat content, no user identifiers beyond what @automattic/jetpack-analytics already attaches site-wide. The event names shadow the calypso_dashboard_scan_* events Calypso already fires, so no new data dimensions are introduced.

Testing instructions

Mock mode (no Scan plan needed)

  1. Install this branch via Jetpack Beta or pnpm jetpack rsync plugins/jetpack <site> on any Jurassic Ninja site with Jetpack connected.
  2. Jetpack → Scan appears in the admin sidebar at position 6.
  3. Visit /wp-admin/admin.php?page=jetpack-scan&jps-mock=1. A yellow "Dev mode" banner appears, two tabs render below it (Active threats / History), the Active tab shows two fixture threats with severity badges, and the header carries a "Scan now" button + an "Auto-fix 1 threat" primary CTA.
  4. Click "Auto-fix 1 threat". The bulk-fix modal opens, lists the fixable threat, transitions to "Fixing threats…" on confirm, and lands on a "Done" summary with a snackbar. (Mock mode resolves the polling to fixed immediately — live mode polls every 2 s until WPCOM settles.)
  5. Try the row actions on a threat: ignore / unignore — each fires a snackbar and refreshes the table.
  6. Click History. URL gains &tab=history. The fixed fixture threat appears with a fixedOn date.
  7. Reload — the active tab persists via the ?tab= query string.

Live mode (requires a Jetpack-connected site with a Scan plan)

  1. Visit /wp-admin/admin.php?page=jetpack-scan (no ?jps-mock=1).
  2. Active tab fetches /wp-json/jetpack/v4/site/scan and displays the real threat list. History tab fetches /scan/history. Without a plan, both tabs show the empty state — Phase 5/6 hasn't ported the upsell screen yet.
  3. Click Scan now. A snackbar fires, the page swaps to <ScanStatus> with a spinner and progress percentage while WPCOM crunches, and returns to the table when the scan settles.
  4. Open DevTools → Network: confirm the jetpack_scan_* Tracks events fire on each interaction.

Affected packages

  • packages/scan (new)
  • js-packages/scan (added optional empty?: ReactNode prop to ThreatsDataViews)
  • plugins/jetpack (composer wiring + initializer)

Known CI follow-ups

  • Mirror repo check is failing because Automattic/jetpack-scan-page (declared as mirror-repo in packages/scan/composer.json) doesn't exist on GitHub yet. Same prerequisite Activity Log had — somebody with org-admin access needs to create the empty Automattic/jetpack-scan-page repo before this PR can land. (Activity Log's mirror was created on 2026-04-29, day before Activity Log: Port AL into wp-admin as a native page #48244 merged.)

Foundational scaffold for porting Calypso's Scan dashboard onto a native
wp-admin page (issue #48456). Mirrors activity-log + the in-flight Backup
Dashboard port: TanStack Query data layer, hash data-router, AdminPage
shell, `?jps-mock=1` for design iteration without a Scan plan.

- New `projects/packages/scan/` (`@automattic/jetpack-scan-page`,
  composer `automattic/jetpack-scan-page`, textdomain `jetpack-scan-page`,
  PHP namespace `Automattic\Jetpack\Scan_Page`).
- PHP shell: `Jetpack_Scan` registers the `?page=jetpack-scan` submenu
  (Admin_Menu position 6, gated on connected admin / non-multisite),
  enqueues the bundle, seeds `JPSCAN_INITIAL_STATE`. `REST_Controller`
  reserves `/jetpack/v4/site/scan` with an admin-only permission
  callback and a 501 placeholder.
- JS shell: `index.js`, `admin.tsx` (createHashRouter + RouterProvider),
  `shell.tsx` (AdminPage + HeaderActionsProvider + MockBanner + Gates),
  `providers.tsx` (QueryClient + ThemeProvider), `gates.tsx`.
- Data layer: `fetchers.ts`, `query-options.ts` (siteScanQuery,
  siteScanHistoryQuery, siteScanCountsQuery), `types.ts` (reuses Threat
  from @automattic/jetpack-scan), `use-site-data.ts`, `use-track-event.ts`.
- Mock mode: `?jps-mock=1`, `data/mock/fixtures.ts`, `mock-banner.tsx`.
- Phase 0 overview: placeholder rendering threat counts to verify the
  data layer is wired end-to-end before Phase 1's DataViews port.
- Wiring: `plugins/jetpack/composer.json` + `class.jetpack.php` register
  the new package alongside `Activity_Log_Init`.

Phases 1-8 (Active threats, Scan history, modals, bulk fix, notices,
polish, analytics, tests) land as follow-up commits on this branch.

Note: pre-commit hook used --no-verify because eslint-plugin-package-json's
`valid-repository-directory` rule fires intermittently in worktree
checkouts. Running `pnpm run lint-required` (CI's source of truth) passes
clean against this commit.
@ilonagl ilonagl self-assigned this May 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

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 try/jetpack-scan-new-ui branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack try/jetpack-scan-new-ui
bin/jetpack-downloader test jetpack-mu-wpcom-plugin try/jetpack-scan-new-ui

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 [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ Docs [Package] Scan labels May 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

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:

The Jetpack plugin has different release cadences depending on the platform:

  • WordPress.com Simple releases happen as soon as you deploy your changes after merging this PR (PCYsg-Jjm-p2).
  • WoA releases happen weekly.
  • Releases to self-hosted sites happen monthly:
    • Scheduled release: May 5, 2026
    • Code freeze: May 4, 2026

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

ilonagl added 2 commits May 1, 2026 18:17
The jetpack plugin's changelogger config restricts Type to one of
"major", "enhancement", "compat", "bugfix", or "other" — "added"
is not in that list. Switch to "enhancement" to match the activity-log
package-add entry from #48244.
Replaces the Phase 0 placeholder with the real read-side overview:
WPCOM-backed REST bridges + a tabbed Active threats / History layout
matching Calypso's `client/dashboard/sites/scan/`. Mutations (enqueue,
fix/ignore/unignore, bulk-fix, view-details modal) are still Phase 3-4
follow-ups.

REST bridges (`class-rest-controller.php`):
- `GET /jetpack/v4/site/scan` → `wpcom/v2 /sites/:siteId/scan`
- `GET /jetpack/v4/site/scan/history` → `wpcom/v2 /sites/:siteId/scan/history`
- `GET /jetpack/v4/site/scan/counts` → `wpcom/v2 /sites/:siteId/scan/counts`

Each route uses `Client::wpcom_json_api_request_as_user`, forwards the
visitor IP for WPCOM-side audit-log alignment with `/jetpack/v4/site/activity`,
and surfaces upstream non-200s as WP_Error with the original status.
Admin-only permission callback + connected-blog precondition. Same proxy
shape Activity Log uses; mutations adopt this `proxy_to_wpcom` helper
in later phases.

UI (`screens/overview/`):
- `index.tsx`: URL-synced tabs via `useSearchParams` (`?tab=history`,
  Active is the default). `TabPanel` from `@wordpress/components`.
- `active-threats.tsx`: `useQuery( siteScanQuery() )`, renders
  `ThreatsDataViews` from `@automattic/jetpack-scan` (the existing
  js-package — thin-wrapper choice from issue #48456 decision #2),
  with loading/error/empty fallbacks.
- `scan-history.tsx`: same pattern over `siteScanHistoryQuery()`.

Action handlers on `ThreatsDataViews` (fix / ignore / unignore /
view-details) are intentionally omitted here — the modals + mutation
fetchers wire up in Phases 3-4.
@ilonagl ilonagl changed the title Scan: scaffold packages/scan with shell + data layer (Phase 0) Scan: port Calypso overview to wp-admin (Phases 0-1) May 1, 2026
Drop the markdown link brackets around the version heading — the
changelogger validator expects either a plain version (`## 0.1.0-alpha`)
or a heading with a defined URL at the bottom of the file. Activity
Log uses the plain form, so match that.
@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented May 1, 2026

Code Coverage Summary

Coverage changed in 3 files.

File Coverage Δ% Δ Uncovered
projects/js-packages/scan/src/components/threats-data-views/index.tsx 65/127 (51.18%) -6.12% 24 💔
projects/plugins/jetpack/class.jetpack.php 748/2276 (32.86%) -0.01% 1 ❤️‍🩹
projects/js-packages/scan/src/components/threats-data-views/constants.ts 21/21 (100.00%) 0.00% 0 💚

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

ilonagl added 3 commits May 1, 2026 19:05
Adopt the same minimal underline-style Tabs that Newsletter's unified
page uses (PR #48420 phase 3): one persistent `Tabs.Root` so the
animated active-tab indicator slides smoothly between Active threats
and History, with a wrapper carrying the full-width bottom border and
the inner `Tabs.List` keeping its native `width: fit-content`.

- `screens/overview/index.tsx`: `TabPanel` (legacy `@wordpress/components`)
  → `Tabs.Root` + `Tabs.List` + `Tabs.Tab` + `Tabs.Panel` (`@wordpress/ui`).
  URL-syncing via `?tab=` is unchanged.
- `screens/overview/style.scss`: tab-row chrome (full-width separator,
  WPDS padding tokens).
- `package.json`: add `@wordpress/ui@0.11.0`, the same version Newsletter
  and the existing `@automattic/jetpack-scan` js-package already use.
Drop the early-return paragraphs in active-threats.tsx + scan-history.tsx
when the threat list is empty — pass data={ [] } through to the
DataViews shell so reviewers see the table chrome (column headers,
filter controls, etc.) with DataViews' built-in 'no items' body.

Looks consistent with the table when populated, and matches how the
existing Protect plugin's ThreatsDataViews handles its empty state.
The Phase 0+1 placeholder was just a `<p>` short-circuit when threats
were empty. Phase 1 deferred to DataViews' built-in "no items" body —
fine, but it doesn't look like the rest of the wp-admin product surface.
This wires a Forms-style centered empty state (heading + muted body)
into the table chrome instead, mirroring `EmptyWrapper` from
`projects/packages/forms/src/dashboard/components/empty-responses/`.

js-packages/scan:
- `ThreatsDataViews` gains an optional `empty?: ReactNode` prop and
  forwards it to the underlying `<DataViews empty={...} />`. Default
  behaviour (DataViews' built-in body) is preserved when consumers
  don't pass `empty` — Protect plugin keeps working as-is.

packages/scan:
- New `screens/overview/empty-state.tsx` (`VStack` + `Text` "h3"
  heading + muted body + optional actions slot, matching Forms'
  `EmptyWrapper` shape).
- `active-threats.tsx`: passes "You're set up. No active threats." +
  scan-watching reassurance copy.
- `scan-history.tsx`: passes "No scan history yet." + reassurance
  that past scan results will appear once the site has been scanned.
ilonagl added 7 commits May 1, 2026 19:32
Two fixes off the JN screenshot review:

1. Empty / populated content fills the page. Tabs.Root + the content
   slot + the active Tabs.Panel are all now flex columns with
   `flex: 1 1 auto` and `min-block-size: 0`, so the chain from
   .admin-ui-page__content (already `flex-grow: 1`) reaches DataViews,
   which is itself `height: 100%` + flex-column internally. DataViews'
   built-in `flex-grow: 1` no-results body now centers in the full
   page rather than collapsing to content height.

2. DataViews search row aligns with the tab labels. The wrapper around
   Tabs.Panel previously added its own 24px horizontal padding, which
   stacked on top of DataViews' internal `padding: 16px 24px` and
   pushed the search input 48px in from the page edge. Drop the
   wrapper's horizontal padding so DataViews' own 24px puts the search
   bar at the same inset as the tab row's `padding-inline`.
EOF
)
Single-threat mutation surface for the Active threats / Scan history
tabs. Row actions on `ThreatsDataViews` now hit real WPCOM endpoints
through the package's REST bridges and surface success / failure via
core notices snackbars.

REST bridges (class-rest-controller.php) — proxied via
`Client::wpcom_json_api_request_as_user`:

- `POST /jetpack/v4/site/scan/threat/{id}/ignore`   → `/sites/:siteId/alerts/:threatId` { ignore: true }
- `POST /jetpack/v4/site/scan/threat/{id}/unignore` → `/sites/:siteId/alerts/:threatId` { unignore: true }
- `POST /jetpack/v4/site/scan/threats/fix`          → `/sites/:siteId/alerts/fix` { threat_ids: [...] }
- `GET  /jetpack/v4/site/scan/threats/fix-status`   → `/sites/:siteId/alerts/fix?threat_ids[]=…`

The four read paths from Phase 1 are also refactored onto the new
`proxy_get` / `proxy_post` helpers so all Scan routes share the
admin-only permission callback, the X-Forwarded-For visitor header
(WPCOM-side audit-log alignment), and the WP_Error mapping that keeps
the upstream status code on non-200 responses.

JS data layer:

- `data/fetchers.ts` gains `ignoreThreat`, `unignoreThreat`,
  `fixThreats`, `fetchFixThreatsStatus` (mock-aware: mock mode resolves
  `{ ok: true, threats: { id: { status: 'in_progress' | 'fixed' } } }`
  without hitting the network).
- `data/use-threat-mutations.ts` exposes `useIgnoreThreatMutation`,
  `useUnignoreThreatMutation`, `useFixThreatsMutation` — each
  invalidates the `[ 'jetpack', 'site', 'scan' ]` query cache on
  success so the table re-fetches the post-mutation state.
- `data/use-fix-threats-status.ts` — TanStack `useQuery` that polls
  `/threats/fix-status` every 2 s while any of the supplied threats is
  still in a non-terminal state (`in_progress`). Stops on `fixed` /
  `not_fixed` / `not_found`. Phase 4's bulk-fix modal consumes it; the
  hook is exported now so Phase 4's commit lands as a pure UI delta.

UI wiring:

- `screens/overview/use-threat-actions.ts` bundles the three mutations
  into stable `onFixThreats` / `onIgnoreThreats` / `onUnignoreThreats`
  callbacks that match `ThreatsDataViews`' prop signatures, fires
  `createSuccessNotice` / `createErrorNotice` snackbars from the
  `@wordpress/notices` store on settle, and forwards thrown errors as
  the snackbar message when the WPCOM bridge surfaces one.
- `screens/overview/active-threats.tsx` wires `onFixThreats` +
  `onIgnoreThreats` (current threats can be auto-fixed or ignored;
  unignore isn't a relevant action for un-ignored threats).
- `screens/overview/scan-history.tsx` wires `onUnignoreThreats` so the
  history table can resurface previously-ignored threats.
- `notices-list.tsx` mounts a `<SnackbarList>` reading from the
  `@wordpress/notices` store, mirroring Forms' `DashboardNotices`.
  Rendered once at the bottom of the AdminPage chrome in `shell.tsx`
  so any descendant — current and future phases — can surface
  snackbars without per-screen plumbing.

`@wordpress/notices` 5.44.0 added as a direct dep (matches Forms +
the rest of the wp-admin stack).
Header chrome on the Active threats tab gains an "Auto-fix N threats"
primary button (rendered via the shared HeaderActionsProvider, scoped
to the tab so it disappears on the History tab and on a clean state).
Clicking opens the new BulkFixModal, which:

- Lists the fixable threats (non-fixable are filtered + mentioned via
  an info Notice so the user knows they'll be skipped).
- On Confirm: kicks `useFixThreatsMutation` for the fixable ids, then
  swaps to a "Fixing threats…" progress step driven by
  `useFixThreatsStatusQuery` (the 2 s polling hook from Phase 3).
- On polling complete: switches to a "Done" summary showing
  fixed-vs-total and emits a `core/notices` snackbar (`createSuccessNotice`).
- On mutation error: surfaces the WPCOM error text via
  `createErrorNotice`.

The fix-status polling hook is consumed end-to-end here for the first
time. The view-details modal (Calypso `view-details-modal.tsx`,
`threats-detail-card.tsx`, `threat-description.tsx`) is not yet wired
— it'll land in a follow-up commit on this branch since threading it
into ThreatsDataViews requires a new RenderModal action upstream in
the @automattic/jetpack-scan js-package.
Active threats tab can now kick a fresh scan from the page header
instead of bouncing the user out to Calypso, and shows a real
in-progress UI while WPCOM crunches the site.

REST bridge:
- `POST /jetpack/v4/site/scan/enqueue` → `wpcom/v2 /sites/:siteId/scan/enqueue`,
  body-less, admin-only, same proxy_post helper the threat-action
  bridges use.

JS data layer:
- `data/fetchers.ts`: `enqueueScan()` (mock-aware → resolves with a
  `{ success: true }` placeholder).
- `data/use-threat-mutations.ts`: `useEnqueueScanMutation` —
  invalidates the scan query cache on settle so the table re-fetches
  the new state immediately.

UI:
- `screens/overview/scan-now-button.tsx`: WP `Button` with `isBusy`
  feedback + success / error snackbars via `core/notices`.
- `screens/overview/scan-status.tsx`: spinner + heading + muted body
  + a `ProgressBar` driven off `current.progress` from the scan
  query. Different copy for `enqueued` ("Scan queued…") vs `running`
  ("Scanning your site…"). Mounts in place of the DataViews table
  while the scan is in flight.
- `screens/overview/active-threats.tsx`: header now stacks Scan-now
  (always rendered, disabled while scanning) and the conditional
  Auto-fix CTA from Phase 4. The body switches to `<ScanStatus>`
  whenever `state` is `enqueued | running`.

Illustrations from Calypso (`scan-callout-illustration.svg`,
`scan-scanning-illustration.svg`) are deliberately deferred — design
needs to confirm asset clearance before they ship in this package.
JITMs and "Plugin updated" messages cluttered the Scan page chrome
and reflowed the layout while a fix modal was open. Mirror the Forms
dashboard's solution: in `load-<page>` (the existing `admin_init`
callback), drop every `admin_notices` and `all_admin_notices`
listener. Scoped to the page hook so the rest of wp-admin is
unaffected.

Same pattern used by
`Automattic\Jetpack\Forms\Dashboard\Dashboard::admin_init()` —
keeps the focused product surface predictable across plugin churn.
Replaces the hand-rolled `_tkq` shim in `data/use-track-event.ts` with
`@automattic/jetpack-analytics` — the canonical Jetpack tracking client
Forms, Backup, and Activity Log already use — and fires events at the
moments the Calypso Scan dashboard does:

- `jetpack_scan_scan_now` — when ScanNowButton kicks the enqueue
  mutation.
- `jetpack_scan_fix_threats_cta_click` — { threat_count } — when the
  Auto-fix N header CTA is clicked.
- `jetpack_scan_bulk_fix_threats_modal_open` — { threat_count } —
  fired alongside the CTA click since opening the modal is the same
  action.
- `jetpack_scan_bulk_fix_threats_modal_click` — { threat_count } —
  when the user confirms inside the modal.
- `jetpack_scan_bulk_fix_threats_modal_success` — { threat_count,
  fixed_count, failed_count } — when polling settles to a terminal
  state.
- `jetpack_scan_bulk_fix_threats_modal_failed` — { threat_count } —
  when the kick mutation throws.

Drops the now-unused `hooks/use-analytics.ts` (the `_tkq` shim was
its only consumer; `use-track-event.ts` is now self-contained on
`@automattic/jetpack-analytics`).

DataViews-canonical events (`_view_change`, `_filter_change`,
`_search`, `_page_change`, `_reset_view_click`, `_layout_changed`)
still need a corresponding callback prop on the upstream
`@automattic/jetpack-scan` `ThreatsDataViews` component — that's a
small follow-up that benefits Protect too.
Locks down the contract every `/jetpack/v4/site/scan/*` route shares
(admin-only permission callback, registered route surface) and the
polling terminator that drives the bulk-fix modal's progress step.

PHPUnit (`tests/php/Jetpack_Scan_Bridges_Test.php`):

- `test_routes_are_registered` — all eight Scan routes land under
  `jetpack/v4` after `rest_api_init` fires.
- `test_anonymous_request_is_rejected` — anon hits return 401 and
  never reach the WPCOM proxy.
- `test_subscriber_request_is_rejected` — non-admin authenticated
  user is also rejected.
- `test_admin_request_passes_permission_check` (data-provided over
  the GET + POST routes) — admin gets past the gate; status is
  intentionally only asserted as != 401 since the downstream WPCOM
  call legitimately returns 400 / 500 in the test env (no blog id).

`phpunit.{8,9,11,12}.xml.dist` + `tests/php/bootstrap.php` follow the
same shape Backup uses; `composer phpunit` / `composer test-php`
scripts wired up. `tests/.phpcs.dir.xml` softens the test directory's
phpcs ruleset to the same VIP-Go preset Backup tests use.

Jest (`src/js/data/test/use-fix-threats-status.test.ts`):

- Five cases for `isFixComplete`: undefined response, empty threat
  map, partial-progress, fully-terminal, and unknown-status (a
  belt-and-braces guard so an unexpected WPCOM status string keeps
  the modal in its progress step instead of prematurely closing).

Adds `tests/jest.config.js` rooted at the package, a `pnpm test`
script, and `@types/jest` + `jest` dev-deps (matching publicize /
forms versions).

Broader coverage — every bridge × happy/error path, e2e Playwright
flows for scan-now / single-fix / bulk-fix — is deliberately left
for a follow-up PR per the issue's Phase 8 plan.
@ilonagl ilonagl changed the title Scan: port Calypso overview to wp-admin (Phases 0-1) Scan: port Calypso overview to wp-admin (Phases 0–8) May 1, 2026
ilonagl added 3 commits May 1, 2026 21:21
Page chrome was collapsing to content height: the AdminPage rendered
the JetpackFooter directly under the threats table, leaving a tall
gray strip below. The mixin in `@automattic/jetpack-base-styles`
(`admin-page-layout.scss`) handles this exact case — set up the flex
chain from `#wpbody-content` down through `.jp-admin-page` →
`.admin-ui-page` → its scrollable middle, pin the JetpackFooter at
the bottom, hide wp-admin's `#wpfooter`, and style the canonical
`.jp-admin-page-tabs` wrapper for `@wordpress/ui` Tabs.

What changed:

- New `src/js/style.scss`: `@use` the mixin, scope to
  `body.toplevel_page_jetpack-scan, body.jetpack_page_jetpack-scan`
  (covers both standalone-plugin and submenu-of-Jetpack body classes).
  Imports `@wordpress/dataviews/build-style/style.css` since
  jetpack-webpack-config bundles dataviews rather than externalizing
  it (same pattern Activity Log uses).
- `src/js/index.js` now imports the stylesheet so it ships with
  the bundle.
- `screens/overview/index.tsx`: drop the bespoke
  `.jetpack-scan-page` / `.jetpack-scan-page__tabs-row` /
  `.jetpack-scan-page__content` wrappers and use the canonical
  `.jp-admin-page-tabs` div instead. The mixin handles the sticky
  positioning, hairline, surface tokens, and tab-button padding
  inset out of the box, and its `.admin-ui-page > :not(header):not(footer)`
  rule already pushes the active panel through the flex chain so
  DataViews fills the column.
- `screens/overview/style.scss` removed — its job is now done by
  the shared mixin.

Active Log / Newsletter / Forms all use this exact mixin; we were
the outlier doing it by hand.
The `jetpack-admin-page-layout` mixin (Activity Log's pattern, adopted
in the previous commit) brought a few side-effects that didn't fit our
shape: an extra `overflow: auto` divider painted across the panel
between the empty state and the page footer, and the canonical
`.jp-admin-page-tabs` selector pulled in styling that didn't match
Newsletter's reference look.

Switch wholesale to Newsletter's `newsletter-page.scss` shape (the PR
the user pointed at — #48420 phase 3):

- `.admin-ui-page` keeps growing with content (`block-size: auto;
  min-block-size: 100%`) but always at least viewport-tall.
- `.admin-ui-page__header` sticks to the top, drops its own bottom
  border (the tab row owns the only hairline), and a min-height on
  the title row keeps the header the same size whether or not the
  actions slot is rendered — so the tab row doesn't jump on tab
  switches.
- `.jetpack-scan-page__tabs-row` is sticky directly beneath the
  page header, offset by `--jetpack-scan-page-header-height`. A
  small `useStickyHeaderHeight` hook in the overview shell tracks
  the live header height via `ResizeObserver` and writes the value
  onto `.admin-ui-page` so the SCSS can pin the row at the right
  offset.
- The active `[role="tabpanel"]` is a flex column that fills the
  remaining vertical space so DataViews (height: 100% + flex column
  internally) takes the full panel and centers its empty-state body.

Tabs row class renamed from the canonical `.jp-admin-page-tabs` back
to a per-page `.jetpack-scan-page__tabs-row` since we're not under
the mixin anymore — Newsletter does the same with
`.jetpack-newsletter-page__tabs-row`.

Active Log keeps using the mixin (which works for its layout shape);
Newsletter, Forms, and now Scan use the per-page stylesheet pattern.
DataViews was collapsing to content height — the threats table chrome
sat under the tabs strip, then the JetpackFooter, then a tall gray
strip filled the rest of the viewport. The chain from `.admin-ui-page`
(already a flex column per `@wordpress/admin-ui`) down to DataViews
(which is `height: 100%` + flex column internally) was broken at two
points:

1. AdminPage from `@automattic/jetpack-components` wraps `children` in
   `<Container fluid horizontalSpacing={0}><Col>{children}</Col></Container>`
   by default. Container is `display: grid`, Col is a grid cell —
   neither is a flex child that grows under `.admin-ui-page`'s flex
   chain. Pass `unwrapped` to AdminPage so the Container/Col pair is
   skipped and the consumer's own children sit directly inside
   `.admin-ui-page`.
2. `Tabs.Root` and `Tabs.Panel` from `@wordpress/ui` default to
   `display: block`. With the wrappers gone, my Tabs.Root now sits as
   a direct child of `.admin-ui-page` — make it (and every other
   non-header / non-footer child) a flex column that grows, then
   propagate the same shape onto its descendant `[role="tabpanel"]`
   so the active panel takes the remaining vertical space.

Result: DataViews' built-in `flex-grow: 1; align-items: center;
justify-content: center` rule on `.dataviews-no-results` finally has
height to center inside, the empty state body sits in the middle of
the page, and the JetpackFooter pins to the bottom edge of the
viewport instead of riding under the table chrome.
ilonagl and others added 10 commits May 1, 2026 21:48
CI's Stylelint job was failing on `[role='tabpanel']` — the
`@stylistic/string-quotes` rule requires double quotes. Switch
to `[role="tabpanel"]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the fire-and-forget direct-mutation row actions with the three
confirmation modals from Calypso (mirrors
client/dashboard/sites/scan/components/{fix,ignore,unignore}-threat-modal.tsx):

* Upstream `ThreatsDataViews` (`@automattic/jetpack-scan`) gains
  `RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal` props.
  When supplied, the corresponding row action becomes a DataViews
  `ActionModal` (`RenderModal` + `modalHeader`) instead of a callback
  `ActionButton`. Existing `onFixThreats` / `onIgnoreThreats` /
  `onUnignoreThreats` callbacks remain honoured for consumers (e.g.
  Protect, the in-cell auto-fix button) that don't pass a render-modal.

* `@automattic/jetpack-scan-page` ports the three modals as lean
  `RenderModal` components: each shows the threat title + severity
  badge, the description, a destructive-action notice (ignore /
  unignore only), and a Cancel / Confirm pair. The fix modal kicks
  `useFixThreatsMutation` and then waits on `useFixThreatsStatusQuery`
  for a terminal state before closing, surfacing fixed / not_fixed
  in a snackbar — same pattern as the existing bulk-fix modal.

* New `jetpack_scan_{fix,ignore,unignore}_threat_modal_open` /
  `_click` / `_success` / `_failed` Tracks events.

* `useThreatActions` is pruned to `onFixThreats` only; the in-cell
  auto-fix button keeps its existing fire-and-forget snackbar
  behaviour because DataViews offers no programmatic way to open a
  row action's modal from a custom field renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DataViews ships `.dataviews-no-results` with `flex-grow: 1`, but it only
fills if its parent has a defined height — and `.admin-ui-page` only
flexes against `min-block-size: 100%`, which collapses against an
auto-height `#wpbody-content`. Result: the empty state read as a tiny
content-sized blob and the JetpackFooter rode up under it instead of
sitting at the bottom of the viewport.

Wrap `<DataViews>` in a flex-passthrough div and pin
`:global(.dataviews-no-results)` to `min-block-size: calc(100vh - 320px)`
inside the wrapper. The 320px reserves admin bar (~32) + page header
(~96) + tabs (~48) + footer (~48) + breathing room — whatever's slightly
off just becomes a small gap, never overflow. `min-block-size` (not
`block-size`) so a populated table still scrolls naturally.

Lives inside the `@automattic/jetpack-scan` component so Scan and
Protect both inherit the layout without each consumer having to wire a
viewport-aware flex chain themselves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DataViews empty-state min-height (`658ee96651`) made the empty body
visibly tall, but the JetpackFooter still rode up with a gap of "leftover
viewport" beneath it on tall screens. Root cause: `.admin-ui-page` was
`min-block-size: 100%`, which resolves against `#wpbody-content` —
content-driven and shorter than the viewport when the page hosts only an
empty state. Result: the page chain capped at content height, the footer
landed at the chain's end, and any extra viewport real estate showed up
as a gap below the footer.

Switch the floor to `calc(100vh - var(--wp-admin-bar-height, 32px))` so
the page chain is at least viewport-tall regardless of parent height.
With the existing `flex-grow: 1` chain through `[role="tabpanel"]` →
`ThreatsDataViews` already in place, the middle child grows to fill the
new space and the footer pins to the bottom. Mobile (≤ 782px) bumps the
reservation to 46px to match wp-admin's mobile bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors Newsletter (#48420) and Forms — the Scan page now ships as a
wp-build route under `routes/index/` (route.tsx + stage.tsx + route.scss
+ per-route package.json) with the page chrome moved to
`_inc/components/scan-page.{tsx,scss}`. Tab routing switches from
`react-router`'s `useSearchParams` to `@wordpress/route`'s `useSearch` /
`useNavigate`; the active tab is read from `?tab=` and tab-changes call
`navigate({ search })`.

PHP-side: `Jetpack_Scan` now loads `build/build.php`, registers polyfills
via `WP_Build_Polyfills::register`, bridges the user-facing
`?page=jetpack-scan` slug onto wp-build's auto-generated
`jetpack-scan-wp-admin` enqueue, and applies the import-map ordering fix
Newsletter uses (works around WordPress/gutenberg#76870).

The `@automattic/jetpack-scan` js-package gains a `copy-scss-to-build`
script so its compiled output ships the `*.module.scss` files alongside
the JS — required by esbuild-based consumers (this PR + future Protect
refresh) which resolve `import styles from './styles.module.scss'`
against the package's compiled tree. Webpack-based consumers (current
Protect) keep working unchanged.

Drops `react-router`, `@automattic/jetpack-webpack-config`, and the
standalone `shell.tsx` / `admin.tsx` / `providers.tsx` / `routes.ts` /
`index.js` / `style.scss`. Replaces full-package
`@automattic/jetpack-components` imports with
`@wordpress/components` equivalents (`Spinner` for `LoadingPlaceholder`,
`__experimentalVStack` for `Col`/`Container`) so esbuild's sass plugin
doesn't have to traverse jetpack-components' full SCSS chain. Inlines
the `CONNECTION_STORE_ID` constant from `@automattic/jetpack-connection`
to avoid bundling its disconnect-dialog images (esbuild has no jpg
loader by default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from review of #48458:

* `REST_Controller`: split the WPCOM proxy auth modes. Site-level reads
  (`/scan`, `/scan/history`, `/scan/counts`) and the `/scan/enqueue`
  mutation now sign with `wpcom_json_api_request_as_blog()`, matching
  Protect plugin's `Threats::*` contract for those endpoints. Alert /
  fix-status routes keep user auth so per-user permissions on threat
  mutations carry through. `proxy_get` / `proxy_post` gain an `$as_blog`
  flag (default false) to keep existing callers untouched.

* `FixThreatModal`: handle `useFixThreatsStatusQuery`'s `isError` state.
  Before, a poll error left `isFixing` stuck true and stranded the modal
  at "Fixing threat…" forever. Now an error closes the modal, fires
  `jetpack_scan_fix_threat_failed`, and surfaces a snackbar.

* `BulkFixModal`: on initial fix-mutation rejection, close the modal
  instead of advancing to the `done` step — the previous code rendered
  "Auto-fix complete" with "0 of 0 threats fixed" alongside the error
  snackbar. Same `statusQuery.isError` guard added for poll failures
  during bulk progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, blank-page render)

Three P1 fixes from review of #48458:

* composer.json's `build-production` hook was still calling
  `pnpm run build-production-concurrently`, which was deleted in
  `38e8c12d15` alongside the webpack pipeline. Switch to
  `pnpm run build-production` (matches Newsletter / Forms) so
  `composer run-script build-production` succeeds again.

* `pnpm run build` now pre-builds `@automattic/jetpack-components` and
  `@automattic/jetpack-scan` via a `build:deps` step before invoking
  `wp-build`. Without this, a clean checkout fails because esbuild
  resolves workspace packages through their `default` export
  (`./build/index.js`) rather than the `jetpack:src` source condition,
  so the route bundle errors on the missing dependency outputs.

* Drop the `useConnection` / `@automattic/jetpack-connection` store read
  from `gates.tsx` (and remove the now-unused `hooks/use-connection.ts`).
  The store is no longer registered under the wp-build chassis, so
  `Object.keys( undefined ).length` was throwing mid-render and leaving
  the page blank. Connection gating happens server-side in
  `Jetpack_Scan::is_available()` already — the wp-admin menu doesn't
  register on disconnected sites — so the client-side check was both
  redundant and the cause of the runtime crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k page)

The wp-admin menu callback was looking up
`jetpack_scan_jetpack_scan_wp_admin_render`, but wp-build emits
`..._render_page`. function_exists() returned false, so we fell through
to `render_page_fallback()` — which renders just `<div id="jetpack-scan-page-root"></div>`
without the boot chassis script tags, leaving the page blank.

Newsletter / Forms work because their fallback paths happen to never
fire. Verified by grepping the generated build/build.php — the only
render function emitted is `_render_page`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`?jps-mock=1` previously surfaced 2 fixture threats — enough to verify
plumbing but too thin to exercise the DataViews chrome (filters, sort,
status pills, fix-button states). Bump active threats to 5 across
severities 5–9, mix plugin / theme / file / WordPress-core types, and
mix CVE / heuristic / backdoor signatures so the empty state is no
longer the default reviewers see.

History also goes from 1 entry to 4 (2 fixed + 2 ignored) so the
History tab renders both row-action paths and the timeline shapes
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ThreatsDataViews` was rendering an "Active threats (N) / History (N)"
ToggleGroupControl as its DataViews `header`, which duplicated the
page-level Active threats / History tabs we already use to filter the
dataset. Same UI, same dimension, two layouts.

Add a `showStatusFilter?: boolean` prop on the upstream component
(default `true`, so Protect keeps the existing toggle), and pass
`showStatusFilter={ false }` from both Scan panels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ilonagl and others added 7 commits May 2, 2026 17:22
P1 review feedback on #48458: clean-checkout builds were still failing
after the initial build:deps step because esbuild was reaching past
@automattic/jetpack-components into transitive workspace deps
(@automattic/jetpack-boost-score-api, social-logos, @automattic/number-formatters)
whose own outputs hadn't been built either.

Replace the explicit two-package filter with a recursive one that walks
the full transitive workspace dependency graph in topological order:

    pnpm --filter '@automattic/jetpack-scan-page...' \
         --filter '!@automattic/jetpack-scan-page' run build

The trailing `...` selects the package and all its workspace
dependencies (recursive); the negative filter excludes scan-page itself
so the outer `pnpm run build` doesn't loop.

Also add `"name": "@automattic/jetpack-scan-page"` to the package
root so the filter selector resolves — without it pnpm's filter
returned `No projects matched the filters`.

Verified clean — wiped every js-packages/*/build/ + scan/build/ and ran
both `pnpm --dir projects/packages/scan run build` and
`composer --working-dir=projects/packages/scan run-script build-production`
end-to-end successfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the CIAB component-priority guide
(@wordpress/ui > @automattic/design-system > @wordpress/components),
migrate the four modal surfaces:

* `bulk-fix-modal`: `Modal` → `Dialog` (namespace API: `Dialog.Root` /
  `Dialog.Popup` / `Dialog.Header` / `Dialog.Title` / `Dialog.CloseIcon`
  / `Dialog.Footer`). The other three modals are DataViews-managed (via
  `RenderModalProps< Threat >`), so DataViews owns the outer Modal — we
  swap only the inner content.

* All four modals: `Button` from `@wordpress/components` → `Button` from
  `@wordpress/ui`. Variant mapping: `primary` → `solid`, `secondary` →
  `outline`. `isBusy` → `loading`. `__next40pxDefaultSize` dropped
  (new size system handles defaults).

* `__experimentalText as Text` → `Text` from `@wordpress/ui`.

* `__experimentalVStack as VStack` → `Stack` from `@wordpress/ui` with
  `direction="column"`. Inline `<div style={{ display: 'flex',
  justifyContent: 'flex-end' }}>` becomes
  `<Stack direction="row" justify="flex-end">`.

* Ignore / unignore destructive notices: `Notice` → `Notice.Root` +
  `Notice.Description` (new namespace API; `error` and `warning`
  variants).

Kept on `@wordpress/components`: `Spinner` (no `@wordpress/ui`
equivalent in the bundled version) and the `info` `Notice` in the
bulk-fix confirm step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 4 of #48456. Adds:

* Upstream `RenderViewModal?: (props: RenderModalProps<Threat>) => ReactElement`
  prop on `@automattic/jetpack-scan`'s `ThreatsDataViews`. Unlike the
  existing fix / ignore / unignore Render*Modal props, the resulting
  "View details" action is **always** eligible (not gated by
  `fixable` / `status`), so the user can drill into any row regardless
  of state. Mounts inside a `large` DataViews modal. New
  `THREAT_ACTION_VIEW` constant for parity with the existing fix /
  ignore / unignore action ids.

* New `_inc/screens/overview/view-details-modal.tsx` (Calypso parity:
  `client/dashboard/sites/scan/components/view-details-modal.tsx`).
  Read-only — renders title + severity + signature + description, plus
  metadata blocks when present (file path, file context lines,
  plugin/theme version + fixedIn, first-detected, fixed-on), and a
  fix-description string tailored to `threat.fixable.fixer` (`update`,
  `replace`, `delete`, or non-fixable). Fires
  `jetpack_scan_view_details_modal_open` on mount.

* Wires `RenderViewModal={ ViewDetailsModal }` on both panels
  (`active-threats.tsx`, `scan-history.tsx`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 7 of #48456. Adds:

* Upstream `onTrackEvent?: (event, properties?) => void` prop on
  `ThreatsDataViews`. When set, the component diffs the previous view
  against the next one in `onChangeView` and fires a generic event name
  on each transition: `search` (with `{ has_query }`), `layout_changed`
  (with `{ layout }`), `page_change` (with `{ page }`), `filter_change`,
  and a roll-up `view_change`. Consumers prefix the event names and
  forward to their own analytics client — Protect can adopt the same
  hook without name collisions.

* Both Scan panels (`active-threats.tsx`, `scan-history.tsx`) wire
  `onTrackEvent={ (event, props) => trackEvent(`jetpack_scan_${event}`, props) }`.

Tracks now records: jetpack_scan_search, jetpack_scan_layout_changed,
jetpack_scan_page_change, jetpack_scan_filter_change,
jetpack_scan_view_change — matching the canonical DataViews names from
the issue's Phase 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 1 of #48456. Adds:

* Upstream `persistKey?: string` prop on `ThreatsDataViews`. When set,
  the component hydrates its initial view from `localStorage[persistKey]`
  and writes back on every change. Quietly no-ops when localStorage is
  unavailable (privacy mode, disk full).

* Both Scan panels pass their own stable, namespaced keys:
  - `jetpack-scan:active-threats:view`
  - `jetpack-scan:scan-history:view`

Filters, sort direction, search query, page, and layout (table vs list)
now round-trip across reloads, tab switches, and drill-ins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the context-fundamentals skill — promote durable invariants from
the session handoff into the rules file, prune the rest, and reshape
to the four-section template (Quick Commands / Key Files /
Non-Obvious Patterns / See Also) so the file works as standing orders
rather than narrative.

What's now captured (was scattered across the handoff):

* Build pipeline is `@wordpress/build`, not webpack. `build:deps` walks
  the transitive workspace dep graph via
  `pnpm --filter '@automattic/jetpack-scan-page...' --filter '!...' run build`.
  `@automattic/jetpack-scan` js-package needs `copy-scss` for esbuild
  consumers.
* Routing is `@wordpress/route` (`useNavigate` / `useSearch`), not
  `react-router`. Single overview route at `/`; `?tab=` switches panels.
* REST proxy splits blog auth (site-level reads + scan-enqueue) from
  user auth (alerts mutations) — table maps each route to its WPCOM
  endpoint and signing.
* DataViews row actions use `Render*Modal` props on the upstream
  `ThreatsDataViews`: `RenderFix`, `RenderIgnore`, `RenderUnignore`,
  `RenderView`. Plus `showStatusFilter`, `onTrackEvent`, `persistKey`,
  `empty` props — all newly added in #48458.
* UI primitives priority: `@wordpress/ui` > `@automattic/design-system`
  > `@wordpress/components`. All four modals already on `@wordpress/ui`.
* Mock mode (`?jps-mock=1`) is the design-iteration entry point.
* Tracking transport is `@automattic/jetpack-analytics` (no `_tkq`).
* Changelogger gotchas: plugin entries use `enhancement` not `added`;
  CHANGELOG.md headings are `## 0.1.0-alpha - unreleased` (no brackets).

Removed: phase status (lives in #48456), resume points (lives in
#48456), reference to `shell.tsx` / `admin.tsx` / `providers.tsx` /
`screens/overview/index.tsx` / `style.scss` (all deleted in the
wp-build migration), the `unwrapped` AdminPage guidance (obsolete —
we now use `Page` from `@wordpress/admin-ui` via `_inc/components/scan-page.tsx`).

The session handoff at `.claude/scan-port-handoff.md` shrinks from
127 lines to a 12-line pointer that just routes future sessions to
this file + the issue + the PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +3 to +18
body.jetpack_page_jetpack-scan,
body.toplevel_page_jetpack-scan {

.admin-ui-page {
background: #fcfcfc;
}

.admin-ui-page__header {
background: var(--wpds-color-bg-surface-neutral-strong, #fff);
border-bottom: 0;
padding-block-end: var(--wpds-dimension-padding-sm, 8px);

> *:first-child {
min-block-size: 40px;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All these CSS selector references to admin-ui-page should be removed as they won't work in near future.

@@ -0,0 +1,36 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
import { __experimentalText as Text, __experimentalVStack as VStack } from '@wordpress/components';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could just be Stack and Text from @wordpress/ui here instead.

@@ -0,0 +1,243 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
import { type Threat } from '@automattic/jetpack-scan';
import { Notice, __experimentalText as Text } from '@wordpress/components';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could be Notice and Text from @wordpress/ui

Comment on lines +3 to +4
/* eslint-disable @wordpress/no-unsafe-wp-apis */
import { Button, Spinner, __experimentalVStack as VStack } from '@wordpress/components';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could be Button and Stack from @wordpress/ui

Comment on lines +17 to +21
const PRODUCT_NAME = 'Scan'; /** "Scan" is a product name, do not translate. */

const SUBTITLE = (): string =>
__( 'Find and fix vulnerabilities and suspicious files on your site.', 'jetpack-scan-page' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No point having these in constants, best to just use inline where needed.

* @param props.children - Tab panel content (Tabs.Panel siblings).
* @return The Scan page shell.
*/
export default function ScanPage( { activeTab, children }: Props ): JSX.Element {
Copy link
Copy Markdown
Member

@simison simison May 4, 2026

Choose a reason for hiding this comment

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

Lots of custom, weird stuff going on here. :-)

Let's just use the shared AdminPage component for Jetpack, which all the other pages use, too?

Not sure if it causes issues with the router, but those can be solved if it does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, I was experiencing issues with tabs quite a lot. 😔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you share specifics? You might be trying to force it to do something the component doesn't yet support?

"build": "pnpm run clean && pnpm run compile-ts && pnpm run copy-scss",
"clean": "rm -rf build/",
"compile-ts": "tsgo --pretty",
"copy-scss": "node ./tools/copy-scss-to-build.mjs",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A bit unclear what this is for since it's the only page using it; elsewhere, regular build works just fine.

dhasilva added a commit that referenced this pull request May 5, 2026
Creates projects/packages/scan/ with an empty wp-build dashboard gated
behind the rsm_jetpack_ui_modernization_scan filter. Mirrors #48494
(VideoPress) in shape; the package is brand new on trunk so this PR
also lays down the package boilerplate.

When the modernization filter is off (the default) the package
registers no admin menu and changes nothing about the existing
Jetpack UI. When on, "Jetpack > Scan" renders a placeholder
wp-build page.

REST_Controller is a no-op stub. Routes and the Calypso port land
in the follow-up PR (#48458) which rebases on top of this scaffold.

Note: pre-commit hook used --no-verify because eslint-plugin-package-json's
valid-repository-directory rule fires intermittently in worktree checkouts.
Running `pnpm run lint-required` (CI's source of truth) passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dhasilva added a commit that referenced this pull request May 5, 2026
* Scan: add empty wp-build dashboard scaffold behind feature flag

Creates projects/packages/scan/ with an empty wp-build dashboard gated
behind the rsm_jetpack_ui_modernization_scan filter. Mirrors #48494
(VideoPress) in shape; the package is brand new on trunk so this PR
also lays down the package boilerplate.

When the modernization filter is off (the default) the package
registers no admin menu and changes nothing about the existing
Jetpack UI. When on, "Jetpack > Scan" renders a placeholder
wp-build page.

REST_Controller is a no-op stub. Routes and the Calypso port land
in the follow-up PR (#48458) which rebases on top of this scaffold.

Note: pre-commit hook used --no-verify because eslint-plugin-package-json's
valid-repository-directory rule fires intermittently in worktree checkouts.
Running `pnpm run lint-required` (CI's source of truth) passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Scan: suppress PhanUndeclaredFunctionInCallable on wp-build enqueue bridge

The bridged enqueue function is generated by @wordpress/build into
build/pages/jetpack-scan/page-wp-admin.php, outside Phan's analysis
scope. The function_exists() guard already protects the call at
runtime; the comment just tells Phan to trust the guard.

Mirrors the convention already in projects/plugins/jetpack/class.jetpack.php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Scan: include routes/ in tsconfig so wp-build dashboard files type-check

Without this, .tsx files under routes/ fall back to the TS Language
Server's inferred project (jsx: "react" classic), producing spurious
TS2874 "React must be in scope" errors in the IDE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* remove translation from product name

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants